vickydmt commited on
Commit
3d209e4
·
verified ·
1 Parent(s): e45e2e3

Upload folder using huggingface_hub

Browse files
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ __pycache__
4
+ *.pyc
5
+ venv
6
+ .env
7
+ db.sqlite3
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ *.session
2
+ *.session-journal
3
+ *.env
4
+ test.py
5
+ __pycache__
6
+
7
+ tgfs/patches/*
8
+ !tgfs/patches/__init__.py
9
+ !tgfs/patches/README.md
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y gcc \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ COPY requirements.txt .
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ COPY . .
12
+
13
+ # Hugging Face deployment requirements
14
+ ENV PORT=7860
15
+ ENV HOST=0.0.0.0
16
+ EXPOSE 7860
17
+
18
+ CMD ["python", "-m", "tgfs"]
LICENSE ADDED
@@ -0,0 +1,662 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU Affero General Public License is a free, copyleft license for
11
+ software and other kinds of works, specifically designed to ensure
12
+ cooperation with the community in the case of network server software.
13
+
14
+ The licenses for most software and other practical works are designed
15
+ to take away your freedom to share and change the works. By contrast,
16
+ our General Public Licenses are intended to guarantee your freedom to
17
+ share and change all versions of a program--to make sure it remains free
18
+ software for all its users.
19
+
20
+ When we speak of free software, we are referring to freedom, not
21
+ price. Our General Public Licenses are designed to make sure that you
22
+ have the freedom to distribute copies of free software (and charge for
23
+ them if you wish), that you receive source code or can get it if you
24
+ want it, that you can change the software or use pieces of it in new
25
+ free programs, and that you know you can do these things.
26
+
27
+ Developers that use our General Public Licenses protect your rights
28
+ with two steps: (1) assert copyright on the software, and (2) offer
29
+ you this License which gives you legal permission to copy, distribute
30
+ and/or modify the software.
31
+
32
+ A secondary benefit of defending all users' freedom is that
33
+ improvements made in alternate versions of the program, if they
34
+ receive widespread use, become available for other developers to
35
+ incorporate. Many developers of free software are heartened and
36
+ encouraged by the resulting cooperation. However, in the case of
37
+ software used on network servers, this result may fail to come about.
38
+ The GNU General Public License permits making a modified version and
39
+ letting the public access it on a server without ever releasing its
40
+ source code to the public.
41
+
42
+ The GNU Affero General Public License is designed specifically to
43
+ ensure that, in such cases, the modified source code becomes available
44
+ to the community. It requires the operator of a network server to
45
+ provide the source code of the modified version running there to the
46
+ users of that server. Therefore, public use of a modified version, on
47
+ a publicly accessible server, gives the public access to the source
48
+ code of the modified version.
49
+
50
+ An older license, called the Affero General Public License and
51
+ published by Affero, was designed to accomplish similar goals. This is
52
+ a different license, not a version of the Affero GPL, but Affero has
53
+ released a new version of the Affero GPL which permits relicensing under
54
+ this license.
55
+
56
+ The precise terms and conditions for copying, distribution and
57
+ modification follow.
58
+
59
+ TERMS AND CONDITIONS
60
+
61
+ 0. Definitions.
62
+
63
+ "This License" refers to version 3 of the GNU Affero General Public License.
64
+
65
+ "Copyright" also means copyright-like laws that apply to other kinds of
66
+ works, such as semiconductor masks.
67
+
68
+ "The Program" refers to any copyrightable work licensed under this
69
+ License. Each licensee is addressed as "you". "Licensees" and
70
+ "recipients" may be individuals or organizations.
71
+
72
+ To "modify" a work means to copy from or adapt all or part of the work
73
+ in a fashion requiring copyright permission, other than the making of an
74
+ exact copy. The resulting work is called a "modified version" of the
75
+ earlier work or a work "based on" the earlier work.
76
+
77
+ A "covered work" means either the unmodified Program or a work based
78
+ on the Program.
79
+
80
+ To "propagate" a work means to do anything with it that, without
81
+ permission, would make you directly or secondarily liable for
82
+ infringement under applicable copyright law, except executing it on a
83
+ computer or modifying a private copy. Propagation includes copying,
84
+ distribution (with or without modification), making available to the
85
+ public, and in some countries other activities as well.
86
+
87
+ To "convey" a work means any kind of propagation that enables other
88
+ parties to make or receive copies. Mere interaction with a user through
89
+ a computer network, with no transfer of a copy, is not conveying.
90
+
91
+ An interactive user interface displays "Appropriate Legal Notices"
92
+ to the extent that it includes a convenient and prominently visible
93
+ feature that (1) displays an appropriate copyright notice, and (2)
94
+ tells the user that there is no warranty for the work (except to the
95
+ extent that warranties are provided), that licensees may convey the
96
+ work under this License, and how to view a copy of this License. If
97
+ the interface presents a list of user commands or options, such as a
98
+ menu, a prominent item in the list meets this criterion.
99
+
100
+ 1. Source Code.
101
+
102
+ The "source code" for a work means the preferred form of the work
103
+ for making modifications to it. "Object code" means any non-source
104
+ form of a work.
105
+
106
+ A "Standard Interface" means an interface that either is an official
107
+ standard defined by a recognized standards body, or, in the case of
108
+ interfaces specified for a particular programming language, one that
109
+ is widely used among developers working in that language.
110
+
111
+ The "System Libraries" of an executable work include anything, other
112
+ than the work as a whole, that (a) is included in the normal form of
113
+ packaging a Major Component, but which is not part of that Major
114
+ Component, and (b) serves only to enable use of the work with that
115
+ Major Component, or to implement a Standard Interface for which an
116
+ implementation is available to the public in source code form. A
117
+ "Major Component", in this context, means a major essential component
118
+ (kernel, window system, and so on) of the specific operating system
119
+ (if any) on which the executable work runs, or a compiler used to
120
+ produce the work, or an object code interpreter used to run it.
121
+
122
+ The "Corresponding Source" for a work in object code form means all
123
+ the source code needed to generate, install, and (for an executable
124
+ work) run the object code and to modify the work, including scripts to
125
+ control those activities. However, it does not include the work's
126
+ System Libraries, or general-purpose tools or generally available free
127
+ programs which are used unmodified in performing those activities but
128
+ which are not part of the work. For example, Corresponding Source
129
+ includes interface definition files associated with source files for
130
+ the work, and the source code for shared libraries and dynamically
131
+ linked subprograms that the work is specifically designed to require,
132
+ such as by intimate data communication or control flow between those
133
+ subprograms and other parts of the work.
134
+
135
+ The Corresponding Source need not include anything that users
136
+ can regenerate automatically from other parts of the Corresponding
137
+ Source.
138
+
139
+ The Corresponding Source for a work in source code form is that
140
+ same work.
141
+
142
+ 2. Basic Permissions.
143
+
144
+ All rights granted under this License are granted for the term of
145
+ copyright on the Program, and are irrevocable provided the stated
146
+ conditions are met. This License explicitly affirms your unlimited
147
+ permission to run the unmodified Program. The output from running a
148
+ covered work is covered by this License only if the output, given its
149
+ content, constitutes a covered work. This License acknowledges your
150
+ rights of fair use or other equivalent, as provided by copyright law.
151
+
152
+ You may make, run and propagate covered works that you do not
153
+ convey, without conditions so long as your license otherwise remains
154
+ in force. You may convey covered works to others for the sole purpose
155
+ of having them make modifications exclusively for you, or provide you
156
+ with facilities for running those works, provided that you comply with
157
+ the terms of this License in conveying all material for which you do
158
+ not control copyright. Those thus making or running the covered works
159
+ for you must do so exclusively on your behalf, under your direction
160
+ and control, on terms that prohibit them from making any copies of
161
+ your copyrighted material outside their relationship with you.
162
+
163
+ Conveying under any other circumstances is permitted solely under
164
+ the conditions stated below. Sublicensing is not allowed; section 10
165
+ makes it unnecessary.
166
+
167
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168
+
169
+ No covered work shall be deemed part of an effective technological
170
+ measure under any applicable law fulfilling obligations under article
171
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172
+ similar laws prohibiting or restricting circumvention of such
173
+ measures.
174
+
175
+ When you convey a covered work, you waive any legal power to forbid
176
+ circumvention of technological measures to the extent such circumvention
177
+ is effected by exercising rights under this License with respect to
178
+ the covered work, and you disclaim any intention to limit operation or
179
+ modification of the work as a means of enforcing, against the work's
180
+ users, your or third parties' legal rights to forbid circumvention of
181
+ technological measures.
182
+
183
+ 4. Conveying Verbatim Copies.
184
+
185
+ You may convey verbatim copies of the Program's source code as you
186
+ receive it, in any medium, provided that you conspicuously and
187
+ appropriately publish on each copy an appropriate copyright notice;
188
+ keep intact all notices stating that this License and any
189
+ non-permissive terms added in accord with section 7 apply to the code;
190
+ keep intact all notices of the absence of any warranty; and give all
191
+ recipients a copy of this License along with the Program.
192
+
193
+ You may charge any price or no price for each copy that you convey,
194
+ and you may offer support or warranty protection for a fee.
195
+
196
+ 5. Conveying Modified Source Versions.
197
+
198
+ You may convey a work based on the Program, or the modifications to
199
+ produce it from the Program, in the form of source code under the
200
+ terms of section 4, provided that you also meet all of these conditions:
201
+
202
+ a) The work must carry prominent notices stating that you modified
203
+ it, and giving a relevant date.
204
+
205
+ b) The work must carry prominent notices stating that it is
206
+ released under this License and any conditions added under section
207
+ 7. This requirement modifies the requirement in section 4 to
208
+ "keep intact all notices".
209
+
210
+ c) You must license the entire work, as a whole, under this
211
+ License to anyone who comes into possession of a copy. This
212
+ License will therefore apply, along with any applicable section 7
213
+ additional terms, to the whole of the work, and all its parts,
214
+ regardless of how they are packaged. This License gives no
215
+ permission to license the work in any other way, but it does not
216
+ invalidate such permission if you have separately received it.
217
+
218
+ d) If the work has interactive user interfaces, each must display
219
+ Appropriate Legal Notices; however, if the Program has interactive
220
+ interfaces that do not display Appropriate Legal Notices, your
221
+ work need not make them do so.
222
+
223
+ A compilation of a covered work with other separate and independent
224
+ works, which are not by their nature extensions of the covered work,
225
+ and which are not combined with it such as to form a larger program,
226
+ in or on a volume of a storage or distribution medium, is called an
227
+ "aggregate" if the compilation and its resulting copyright are not
228
+ used to limit the access or legal rights of the compilation's users
229
+ beyond what the individual works permit. Inclusion of a covered work
230
+ in an aggregate does not cause this License to apply to the other
231
+ parts of the aggregate.
232
+
233
+ 6. Conveying Non-Source Forms.
234
+
235
+ You may convey a covered work in object code form under the terms
236
+ of sections 4 and 5, provided that you also convey the
237
+ machine-readable Corresponding Source under the terms of this License,
238
+ in one of these ways:
239
+
240
+ a) Convey the object code in, or embodied in, a physical product
241
+ (including a physical distribution medium), accompanied by the
242
+ Corresponding Source fixed on a durable physical medium
243
+ customarily used for software interchange.
244
+
245
+ b) Convey the object code in, or embodied in, a physical product
246
+ (including a physical distribution medium), accompanied by a
247
+ written offer, valid for at least three years and valid for as
248
+ long as you offer spare parts or customer support for that product
249
+ model, to give anyone who possesses the object code either (1) a
250
+ copy of the Corresponding Source for all the software in the
251
+ product that is covered by this License, on a durable physical
252
+ medium customarily used for software interchange, for a price no
253
+ more than your reasonable cost of physically performing this
254
+ conveying of source, or (2) access to copy the
255
+ Corresponding Source from a network server at no charge.
256
+
257
+ c) Convey individual copies of the object code with a copy of the
258
+ written offer to provide the Corresponding Source. This
259
+ alternative is allowed only occasionally and noncommercially, and
260
+ only if you received the object code with such an offer, in accord
261
+ with subsection 6b.
262
+
263
+ d) Convey the object code by offering access from a designated
264
+ place (gratis or for a charge), and offer equivalent access to the
265
+ Corresponding Source in the same way through the same place at no
266
+ further charge. You need not require recipients to copy the
267
+ Corresponding Source along with the object code. If the place to
268
+ copy the object code is a network server, the Corresponding Source
269
+ may be on a different server (operated by you or a third party)
270
+ that supports equivalent copying facilities, provided you maintain
271
+ clear directions next to the object code saying where to find the
272
+ Corresponding Source. Regardless of what server hosts the
273
+ Corresponding Source, you remain obligated to ensure that it is
274
+ available for as long as needed to satisfy these requirements.
275
+
276
+ e) Convey the object code using peer-to-peer transmission, provided
277
+ you inform other peers where the object code and Corresponding
278
+ Source of the work are being offered to the general public at no
279
+ charge under subsection 6d.
280
+
281
+ A separable portion of the object code, whose source code is excluded
282
+ from the Corresponding Source as a System Library, need not be
283
+ included in conveying the object code work.
284
+
285
+ A "User Product" is either (1) a "consumer product", which means any
286
+ tangible personal property which is normally used for personal, family,
287
+ or household purposes, or (2) anything designed or sold for incorporation
288
+ into a dwelling. In determining whether a product is a consumer product,
289
+ doubtful cases shall be resolved in favor of coverage. For a particular
290
+ product received by a particular user, "normally used" refers to a
291
+ typical or common use of that class of product, regardless of the status
292
+ of the particular user or of the way in which the particular user
293
+ actually uses, or expects or is expected to use, the product. A product
294
+ is a consumer product regardless of whether the product has substantial
295
+ commercial, industrial or non-consumer uses, unless such uses represent
296
+ the only significant mode of use of the product.
297
+
298
+ "Installation Information" for a User Product means any methods,
299
+ procedures, authorization keys, or other information required to install
300
+ and execute modified versions of a covered work in that User Product from
301
+ a modified version of its Corresponding Source. The information must
302
+ suffice to ensure that the continued functioning of the modified object
303
+ code is in no case prevented or interfered with solely because
304
+ modification has been made.
305
+
306
+ If you convey an object code work under this section in, or with, or
307
+ specifically for use in, a User Product, and the conveying occurs as
308
+ part of a transaction in which the right of possession and use of the
309
+ User Product is transferred to the recipient in perpetuity or for a
310
+ fixed term (regardless of how the transaction is characterized), the
311
+ Corresponding Source conveyed under this section must be accompanied
312
+ by the Installation Information. But this requirement does not apply
313
+ if neither you nor any third party retains the ability to install
314
+ modified object code on the User Product (for example, the work has
315
+ been installed in ROM).
316
+
317
+ The requirement to provide Installation Information does not include a
318
+ requirement to continue to provide support service, warranty, or updates
319
+ for a work that has been modified or installed by the recipient, or for
320
+ the User Product in which it has been modified or installed. Access to a
321
+ network may be denied when the modification itself materially and
322
+ adversely affects the operation of the network or violates the rules and
323
+ protocols for communication across the network.
324
+
325
+ Corresponding Source conveyed, and Installation Information provided,
326
+ in accord with this section must be in a format that is publicly
327
+ documented (and with an implementation available to the public in
328
+ source code form), and must require no special password or key for
329
+ unpacking, reading or copying.
330
+
331
+ 7. Additional Terms.
332
+
333
+ "Additional permissions" are terms that supplement the terms of this
334
+ License by making exceptions from one or more of its conditions.
335
+ Additional permissions that are applicable to the entire Program shall
336
+ be treated as though they were included in this License, to the extent
337
+ that they are valid under applicable law. If additional permissions
338
+ apply only to part of the Program, that part may be used separately
339
+ under those permissions, but the entire Program remains governed by
340
+ this License without regard to the additional permissions.
341
+
342
+ When you convey a copy of a covered work, you may at your option
343
+ remove any additional permissions from that copy, or from any part of
344
+ it. (Additional permissions may be written to require their own
345
+ removal in certain cases when you modify the work.) You may place
346
+ additional permissions on material, added by you to a covered work,
347
+ for which you have or can give appropriate copyright permission.
348
+
349
+ Notwithstanding any other provision of this License, for material you
350
+ add to a covered work, you may (if authorized by the copyright holders of
351
+ that material) supplement the terms of this License with terms:
352
+
353
+ a) Disclaiming warranty or limiting liability differently from the
354
+ terms of sections 15 and 16 of this License; or
355
+
356
+ b) Requiring preservation of specified reasonable legal notices or
357
+ author attributions in that material or in the Appropriate Legal
358
+ Notices displayed by works containing it; or
359
+
360
+ c) Prohibiting misrepresentation of the origin of that material, or
361
+ requiring that modified versions of such material be marked in
362
+ reasonable ways as different from the original version; or
363
+
364
+ d) Limiting the use for publicity purposes of names of licensors or
365
+ authors of the material; or
366
+
367
+ e) Declining to grant rights under trademark law for use of some
368
+ trade names, trademarks, or service marks; or
369
+
370
+ f) Requiring indemnification of licensors and authors of that
371
+ material by anyone who conveys the material (or modified versions of
372
+ it) with contractual assumptions of liability to the recipient, for
373
+ any liability that these contractual assumptions directly impose on
374
+ those licensors and authors.
375
+
376
+ All other non-permissive additional terms are considered "further
377
+ restrictions" within the meaning of section 10. If the Program as you
378
+ received it, or any part of it, contains a notice stating that it is
379
+ governed by this License along with a term that is a further
380
+ restriction, you may remove that term. If a license document contains
381
+ a further restriction but permits relicensing or conveying under this
382
+ License, you may add to a covered work material governed by the terms
383
+ of that license document, provided that the further restriction does
384
+ not survive such relicensing or conveying.
385
+
386
+ If you add terms to a covered work in accord with this section, you
387
+ must place, in the relevant source files, a statement of the
388
+ additional terms that apply to those files, or a notice indicating
389
+ where to find the applicable terms.
390
+
391
+ Additional terms, permissive or non-permissive, may be stated in the
392
+ form of a separately written license, or stated as exceptions;
393
+ the above requirements apply either way.
394
+
395
+ 8. Termination.
396
+
397
+ You may not propagate or modify a covered work except as expressly
398
+ provided under this License. Any attempt otherwise to propagate or
399
+ modify it is void, and will automatically terminate your rights under
400
+ this License (including any patent licenses granted under the third
401
+ paragraph of section 11).
402
+
403
+ However, if you cease all violation of this License, then your
404
+ license from a particular copyright holder is reinstated (a)
405
+ provisionally, unless and until the copyright holder explicitly and
406
+ finally terminates your license, and (b) permanently, if the copyright
407
+ holder fails to notify you of the violation by some reasonable means
408
+ prior to 60 days after the cessation.
409
+
410
+ Moreover, your license from a particular copyright holder is
411
+ reinstated permanently if the copyright holder notifies you of the
412
+ violation by some reasonable means, this is the first time you have
413
+ received notice of violation of this License (for any work) from that
414
+ copyright holder, and you cure the violation prior to 30 days after
415
+ your receipt of the notice.
416
+
417
+ Termination of your rights under this section does not terminate the
418
+ licenses of parties who have received copies or rights from you under
419
+ this License. If your rights have been terminated and not permanently
420
+ reinstated, you do not qualify to receive new licenses for the same
421
+ material under section 10.
422
+
423
+ 9. Acceptance Not Required for Having Copies.
424
+
425
+ You are not required to accept this License in order to receive or
426
+ run a copy of the Program. Ancillary propagation of a covered work
427
+ occurring solely as a consequence of using peer-to-peer transmission
428
+ to receive a copy likewise does not require acceptance. However,
429
+ nothing other than this License grants you permission to propagate or
430
+ modify any covered work. These actions infringe copyright if you do
431
+ not accept this License. Therefore, by modifying or propagating a
432
+ covered work, you indicate your acceptance of this License to do so.
433
+
434
+ 10. Automatic Licensing of Downstream Recipients.
435
+
436
+ Each time you convey a covered work, the recipient automatically
437
+ receives a license from the original licensors, to run, modify and
438
+ propagate that work, subject to this License. You are not responsible
439
+ for enforcing compliance by third parties with this License.
440
+
441
+ An "entity transaction" is a transaction transferring control of an
442
+ organization, or substantially all assets of one, or subdividing an
443
+ organization, or merging organizations. If propagation of a covered
444
+ work results from an entity transaction, each party to that
445
+ transaction who receives a copy of the work also receives whatever
446
+ licenses to the work the party's predecessor in interest had or could
447
+ give under the previous paragraph, plus a right to possession of the
448
+ Corresponding Source of the work from the predecessor in interest, if
449
+ the predecessor has it or can get it with reasonable efforts.
450
+
451
+ You may not impose any further restrictions on the exercise of the
452
+ rights granted or affirmed under this License. For example, you may
453
+ not impose a license fee, royalty, or other charge for exercise of
454
+ rights granted under this License, and you may not initiate litigation
455
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
456
+ any patent claim is infringed by making, using, selling, offering for
457
+ sale, or importing the Program or any portion of it.
458
+
459
+ 11. Patents.
460
+
461
+ A "contributor" is a copyright holder who authorizes use under this
462
+ License of the Program or a work on which the Program is based. The
463
+ work thus licensed is called the contributor's "contributor version".
464
+
465
+ A contributor's "essential patent claims" are all patent claims
466
+ owned or controlled by the contributor, whether already acquired or
467
+ hereafter acquired, that would be infringed by some manner, permitted
468
+ by this License, of making, using, or selling its contributor version,
469
+ but do not include claims that would be infringed only as a
470
+ consequence of further modification of the contributor version. For
471
+ purposes of this definition, "control" includes the right to grant
472
+ patent sublicenses in a manner consistent with the requirements of
473
+ this License.
474
+
475
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
476
+ patent license under the contributor's essential patent claims, to
477
+ make, use, sell, offer for sale, import and otherwise run, modify and
478
+ propagate the contents of its contributor version.
479
+
480
+ In the following three paragraphs, a "patent license" is any express
481
+ agreement or commitment, however denominated, not to enforce a patent
482
+ (such as an express permission to practice a patent or covenant not to
483
+ sue for patent infringement). To "grant" such a patent license to a
484
+ party means to make such an agreement or commitment not to enforce a
485
+ patent against the party.
486
+
487
+ If you convey a covered work, knowingly relying on a patent license,
488
+ and the Corresponding Source of the work is not available for anyone
489
+ to copy, free of charge and under the terms of this License, through a
490
+ publicly available network server or other readily accessible means,
491
+ then you must either (1) cause the Corresponding Source to be so
492
+ available, or (2) arrange to deprive yourself of the benefit of the
493
+ patent license for this particular work, or (3) arrange, in a manner
494
+ consistent with the requirements of this License, to extend the patent
495
+ license to downstream recipients. "Knowingly relying" means you have
496
+ actual knowledge that, but for the patent license, your conveying the
497
+ covered work in a country, or your recipient's use of the covered work
498
+ in a country, would infringe one or more identifiable patents in that
499
+ country that you have reason to believe are valid.
500
+
501
+ If, pursuant to or in connection with a single transaction or
502
+ arrangement, you convey, or propagate by procuring conveyance of, a
503
+ covered work, and grant a patent license to some of the parties
504
+ receiving the covered work authorizing them to use, propagate, modify
505
+ or convey a specific copy of the covered work, then the patent license
506
+ you grant is automatically extended to all recipients of the covered
507
+ work and works based on it.
508
+
509
+ A patent license is "discriminatory" if it does not include within
510
+ the scope of its coverage, prohibits the exercise of, or is
511
+ conditioned on the non-exercise of one or more of the rights that are
512
+ specifically granted under this License. You may not convey a covered
513
+ work if you are a party to an arrangement with a third party that is
514
+ in the business of distributing software, under which you make payment
515
+ to the third party based on the extent of your activity of conveying
516
+ the work, and under which the third party grants, to any of the
517
+ parties who would receive the covered work from you, a discriminatory
518
+ patent license (a) in connection with copies of the covered work
519
+ conveyed by you (or copies made from those copies), or (b) primarily
520
+ for and in connection with specific products or compilations that
521
+ contain the covered work, unless you entered into that arrangement,
522
+ or that patent license was granted, prior to 28 March 2007.
523
+
524
+ Nothing in this License shall be construed as excluding or limiting
525
+ any implied license or other defenses to infringement that may
526
+ otherwise be available to you under applicable patent law.
527
+
528
+ 12. No Surrender of Others' Freedom.
529
+
530
+ If conditions are imposed on you (whether by court order, agreement or
531
+ otherwise) that contradict the conditions of this License, they do not
532
+ excuse you from the conditions of this License. If you cannot convey a
533
+ covered work so as to satisfy simultaneously your obligations under this
534
+ License and any other pertinent obligations, then as a consequence you may
535
+ not convey it at all. For example, if you agree to terms that obligate you
536
+ to collect a royalty for further conveying from those to whom you convey
537
+ the Program, the only way you could satisfy both those terms and this
538
+ License would be to refrain entirely from conveying the Program.
539
+
540
+ 13. Remote Network Interaction; Use with the GNU General Public License.
541
+
542
+ Notwithstanding any other provision of this License, if you modify the
543
+ Program, your modified version must prominently offer all users
544
+ interacting with it remotely through a computer network (if your version
545
+ supports such interaction) an opportunity to receive the Corresponding
546
+ Source of your version by providing access to the Corresponding Source
547
+ from a network server at no charge, through some standard or customary
548
+ means of facilitating copying of software. This Corresponding Source
549
+ shall include the Corresponding Source for any work covered by version 3
550
+ of the GNU General Public License that is incorporated pursuant to the
551
+ following paragraph.
552
+
553
+ Notwithstanding any other provision of this License, you have
554
+ permission to link or combine any covered work with a work licensed
555
+ under version 3 of the GNU General Public License into a single
556
+ combined work, and to convey the resulting work. The terms of this
557
+ License will continue to apply to the part which is the covered work,
558
+ but the work with which it is combined will remain governed by version
559
+ 3 of the GNU General Public License.
560
+
561
+ 14. Revised Versions of this License.
562
+
563
+ The Free Software Foundation may publish revised and/or new versions of
564
+ the GNU Affero General Public License from time to time. Such new versions
565
+ will be similar in spirit to the present version, but may differ in detail to
566
+ address new problems or concerns.
567
+
568
+ Each version is given a distinguishing version number. If the
569
+ Program specifies that a certain numbered version of the GNU Affero General
570
+ Public License "or any later version" applies to it, you have the
571
+ option of following the terms and conditions either of that numbered
572
+ version or of any later version published by the Free Software
573
+ Foundation. If the Program does not specify a version number of the
574
+ GNU Affero General Public License, you may choose any version ever published
575
+ by the Free Software Foundation.
576
+
577
+ If the Program specifies that a proxy can decide which future
578
+ versions of the GNU Affero General Public License can be used, that proxy's
579
+ public statement of acceptance of a version permanently authorizes you
580
+ to choose that version for the Program.
581
+
582
+ Later license versions may give you additional or different
583
+ permissions. However, no additional obligations are imposed on any
584
+ author or copyright holder as a result of your choosing to follow a
585
+ later version.
586
+
587
+ 15. Disclaimer of Warranty.
588
+
589
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597
+
598
+ 16. Limitation of Liability.
599
+
600
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608
+ SUCH DAMAGES.
609
+
610
+ 17. Interpretation of Sections 15 and 16.
611
+
612
+ If the disclaimer of warranty and limitation of liability provided
613
+ above cannot be given local legal effect according to their terms,
614
+ reviewing courts shall apply local law that most closely approximates
615
+ an absolute waiver of all civil liability in connection with the
616
+ Program, unless a warranty or assumption of liability accompanies a
617
+ copy of the Program in return for a fee.
618
+
619
+ END OF TERMS AND CONDITIONS
620
+
621
+ How to Apply These Terms to Your New Programs
622
+
623
+ If you develop a new program, and you want it to be of the greatest
624
+ possible use to the public, the best way to achieve this is to make it
625
+ free software which everyone can redistribute and change under these terms.
626
+
627
+ To do so, attach the following notices to the program. It is safest
628
+ to attach them to the start of each source file to most effectively
629
+ state the exclusion of warranty; and each file should have at least
630
+ the "copyright" line and a pointer to where the full notice is found.
631
+
632
+ TG-FileStream – A web server and Telegram client that acts as a proxy between the Telegram servers and an HTTP client to enable file downloads.
633
+
634
+ Copyright (C)2025-2026 DeekshithSH
635
+
636
+ This program is free software: you can redistribute it and/or modify
637
+ it under the terms of the GNU Affero General Public License as published by
638
+ the Free Software Foundation, either version 3 of the License, or
639
+ (at your option) any later version.
640
+
641
+ This program is distributed in the hope that it will be useful,
642
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
643
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
644
+ GNU Affero General Public License for more details.
645
+
646
+ You should have received a copy of the GNU Affero General Public License
647
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
648
+
649
+ Also add information on how to contact you by electronic and paper mail.
650
+
651
+ If your software can interact with users remotely through a computer
652
+ network, you should also make sure that it provides a way for users to
653
+ get its source. For example, if your program is a web application, its
654
+ interface could display a "Source" link that leads users to an archive
655
+ of the code. There are many ways you could offer source, and different
656
+ solutions will be better for different programs; see section 13 for the
657
+ specific requirements.
658
+
659
+ You should also get your employer (if you work as a programmer) or school,
660
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
661
+ For more information on this, and how to apply and follow the GNU AGPL, see
662
+ <http://www.gnu.org/licenses/>.
README.md CHANGED
@@ -1,11 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Tgfs
3
- emoji: 😻
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- license: mit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+
3
+ > This project is released under the **GNU AGPL v3** license.
4
+ > You are free to use, modify, and distribute it — as long as you share your changes under the same license.
5
+
6
+ **TG-FileStream** is a lightweight web server and Telegram client that acts as a proxy between Telegram servers and HTTP clients, allowing direct downloads of Telegram media files via HTTP.
7
+
8
+ > 📌 Check out [TODO.md](./TODO.md) for the latest development progress and planned features.
9
+
10
+ ---
11
+
12
+ ## 📝 Notes
13
+
14
+ - If you want one without database checkout [DeekshithSH/tgfilestream](https://github.com/DeekshithSH/tgfilestream) or [simple branch](https://github.com/SpringsFern/TG-FileStream/tree/simple)
15
+
16
  ---
17
+
18
+ ## 🚀 Features
19
+
20
+ - Download Telegram media via HTTP links
21
+ - Fast, concurrent chunked downloading
22
+
23
+ ---
24
+
25
+ ## 🛠️ Setup
26
+
27
+ ### 1. Clone the repository
28
+
29
+ ```bash
30
+ git clone https://github.com/SpringsFern/TG-FileStream.git
31
+ cd TG-FileStream
32
+ ```
33
+
34
+ ### 2. Install dependencies
35
+
36
+ ```bash
37
+ pip install -r requirements.txt
38
+ ```
39
+
40
+ ### 3. Create a `.env` file
41
+
42
+ Store the required environment variables in a `.env` file:
43
+
44
+ ```env
45
+ API_ID=1234567
46
+ API_HASH=1a2b3c4d5e6f7g8h9i0jklmnopqrstuv
47
+ BOT_TOKEN=1234567890:AAExampleBotTokenGeneratedHere
48
+ BIN_CHANNEL=-1002605638795
49
+ HOST=0.0.0.0
50
+ PORT=8080
51
+ PUBLIC_URL=http://127.0.0.1:8080
52
+ DB_BACKEND=mongodb
53
+ MONGODB_URI=mongodb://admin:pAswaRd@192.168.27.1
54
+ ```
55
+
56
+ ### 4. Run the server
57
+
58
+ ```bash
59
+ python3 -m tgfs
60
+ ```
61
+
62
  ---
63
 
64
+ ## ⚙️ Environment Variables
65
+
66
+ | Variable | Required/Default | Description |
67
+ | -------------------- | ---------------------- | ---------------------------------------------------------------------------- |
68
+ | `API_ID` | ✅ | App ID from [my.telegram.org](https://my.telegram.org) |
69
+ | `API_HASH` | ✅ | API hash from [my.telegram.org](https://my.telegram.org) |
70
+ | `BOT_TOKEN` | ✅ | Bot token from [@BotFather](https://t.me/BotFather) |
71
+ | `BIN_CHANNEL` | ✅ | Channel ID where files sent to the bot are stored |
72
+ | `DB_BACKEND` | ✅ | Which Database server to use. either `mongodb` or `mysql` |
73
+ | `HOST` | `0.0.0.0` | Host address to bind the server (default: `0.0.0.0`) |
74
+ | `PORT` | `8080` | Port to run the server on (default: `8080`) |
75
+ | `PUBLIC_URL` | `https://0.0.0.0:8080` | Public-facing URL used to generate download links |
76
+ | `DEBUG` | `False` | Show Extra Logs |
77
+ | `CONNECTION_LIMIT` | `5` | Number of connections to create per DC for a single client |
78
+ | `DOWNLOAD_PART_SIZE` | `1048576 (1MB)` | Number of bytes to request in a single chunk |
79
+ | `NO_UPDATE` | `False` | Whether to reply to messages sent to the bot (True to disable replies) |
80
+ | `SEQUENTIAL_UPDATES` | `False` | Handle telegram updates sequentially |
81
+ | `FILE_INDEX_LIMIT` | `10` | Number of files to display at once with `/files` command |
82
+ | `MAX_WARNS` | `3` | Maximum number of warns before user get banned |
83
+ | `ADMIN_IDS` | `None` | User id of users who can use admin commands. Each id is seperated by `,` |
84
+ | `ALLOWED_IDS` | `None` | Only users with these IDs can use the bot. Separate multiple IDs with `,` |
85
+
86
+
87
+ ### Multi Token Environment Variables
88
+ | Variable | Required/Default | Description |
89
+ | -------------- | ---------------- | ---------------------------------------------------------------------------- |
90
+ | `MULTI_TOKENx` | ✅ | Use Multiple Telegram Clients when downloading files to avoid flood wait, Replace x with Number |
91
+ | | | Example: |
92
+ | `MULTI_TOKEN1` | | MULTI_TOKEN1=1234567890:AAExampleBotTokenGeneratedHere|
93
+ | `MULTI_TOKEN2` | | MULTI_TOKEN2=0987654321:AAExampleBotTokenGeneratedHere|
94
+ | `MULTI_TOKEN3` | | MULTI_TOKEN3=5432167890:AAExampleBotTokenGeneratedHere|
95
+
96
+ ### MySQL Environment Variables
97
+ Set the following variables if you choose MySQL as the database in `DB_BACKEND`
98
+
99
+ | Variable | Required/Default | Description |
100
+ | ---------------- | ---------------- | ------------------------------------------ |
101
+ | `MYSQL_HOST` | ✅ | MySQL DataBase Host Name |
102
+ | `MYSQL_PORT` | ✅ | MySQL Database Port Number |
103
+ | `MYSQL_USER` | ✅ | MySQL Database Username |
104
+ | `MYSQL_PASSWORD` | ✅ | MySQL Database Password |
105
+ | `MYSQL_DB` | ✅ | MySQL Database Name |
106
+ | `MYSQL_MINSIZE` | `1` | Minimum sizes of the MySQL Connection pool |
107
+ | `MYSQL_MAXSIZE` | `5` | Maximum sizes of the MySQL Connection pool |
108
+
109
+ ### MongoDB Environment Variables
110
+ Set the following variables if you choose MongoDB as the database in `DB_BACKEND`
111
+
112
+ | Variable | Required/Default | Description |
113
+ | ---------------- | ---------------- | --------------------- |
114
+ | `MONGODB_URI` | ✅ | MongoDB Database URI |
115
+ | `MONGODB_DBNAME` | `TGFS` | MongoDB Database name |
116
+
117
+ ---
118
+
119
+ ## 📂 Usage
120
+
121
+ Once the server is running, you can:
122
+
123
+ - Access Telegram media files via HTTP:
124
+
125
+ - Or simply send a file to your bot, and it will respond with a download link.
126
+
127
+ This will stream the file directly from Telegram servers to the client.
128
+
129
+ ---
130
+
131
+ ## 🛠️ Contributing & Reporting Issues
132
+
133
+ Found a bug or have a feature request? Please [open an issue](https://github.com/SpringsFern/TG-FileStream/issues) on GitHub.
134
+
135
+ ### 🐞 Reporting Issues
136
+ When reporting a bug, **please include**:
137
+ - Steps to reproduce the issue
138
+ - Expected behavior vs actual behavior
139
+ - Relevant logs, screenshots, or error messages (if any)
140
+ - Environment details (OS, Python version, etc.)
141
+
142
+ **Example issue title:**
143
+ `[Bug] Download fails for large files`
144
+
145
+ ### 💡 Requesting Features
146
+ When suggesting a new feature, **please include**:
147
+ - A clear and concise description of the feature
148
+ - The motivation or use case for it
149
+ - Expected behavior (input/output examples if applicable)
150
+ - Any alternatives you've considered
151
+
152
+ **Example feature title:**
153
+ `[Feature] Add support for viewing generated links`
154
+
155
+ ---
156
+
157
+ Contributions are welcome!
158
+ Feel free to fork the project and open a pull request.
159
+
160
+ > 🔍 **Note:** Make sure to test your code thoroughly before submitting a PR to help maintain stability and performance.
161
+
162
+ ---
163
+
164
+ ## 💡 Credits
165
+
166
+ - **Deekshith SH** – Me
167
+ - **Tulir** – Original author of [`tgfilestream`](https://github.com/tulir/tgfilestream), whose code inspired this project and is referenced in `paralleltransfer.py`
168
+
169
+ ---
TODO.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ✅ TODO
2
+
3
+ | Task | Status |
4
+ |------------------------------------------------------------------------------------------|-------------|
5
+ | Rewrite the entire project based on the Telethon client | ✅ Done |
6
+ | Reuse code from `tgfilestream` (`paralleltransfer.py`) to fetch files from Telegram | ✅ Done |
7
+ | Use multiple Telegram Bot accounts to avoid flood wait | ✅ Done |
8
+ | Add support for multiple databases | ✅ Done |
9
+ | Support for multiple languages | ✅ Done |
10
+ | Share File Info Cache between multiple clients | Not Planned |
11
+ | Prefetch chunks | Not Planned |
12
+ | Cache Files | Not Planned |
13
+ | Add More options in /files command | ⏳ Pending |
14
+
15
+ ---
16
+
17
+ ## 🎯 Goal [⏳ Pending]
18
+
19
+ > **Keep the links generated by the bot as consistent as possible.**
20
+ > Even if the internal code changes, previously generated links should still work.
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ telethon
2
+ aiohttp
3
+ python-dotenv
4
+ cryptg
5
+ aiomysql
6
+ motor
7
+ dnspython
tgfs/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __version__ = "0.0.1"
2
+ __author__ = "Deekshith SH <deekshith@spri.in>"
tgfs/__main__.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import asyncio
18
+ import traceback
19
+ from aiohttp import web
20
+ from telethon import functions
21
+ from telethon.tl.types import User
22
+
23
+ from tgfs.app import init_app
24
+ from tgfs.info import Version, __version__
25
+ from tgfs.log import log
26
+ from tgfs.config import Config
27
+ from tgfs.paralleltransfer import ParallelTransferrer
28
+ from tgfs.telegram import client, load_plugins, multi_clients, start_clients
29
+ from tgfs.database import DB
30
+ from tgfs.utils.utils import load_configs, load_patches
31
+
32
+ load_patches(Config.PATCH_PATH)
33
+
34
+ app = init_app()
35
+ runner = web.AppRunner(app, handler_cancellation=True)
36
+
37
+ async def additional_check():
38
+ version = await DB.db.get_config_value("VERSION")
39
+ if version != __version__:
40
+ if not version:
41
+ await DB.db.set_config_value("VERSION", __version__)
42
+ return
43
+ _version = version.split(".", maxsplit=3)
44
+ major = int(_version[0])
45
+ minor = int(_version[1])
46
+ patch = int(_version[2])
47
+ if minor != Version.minor or major != Version.major:
48
+ # ToDo: Create Migration Script execute based on version
49
+ log.warning("version mismatch detected. Old version: %s, Current version: %s", version, __version__)
50
+ await DB.db.set_config_value("VERSION", __version__)
51
+ await DB.db.set_config_value("OLD_VERSION", version)
52
+
53
+ async def start() -> None:
54
+ log.info("Initializing Database")
55
+ await DB.init()
56
+ await load_configs()
57
+ log.info("Running Checks")
58
+ await additional_check()
59
+ log.info("Starting Telegram Client")
60
+ await client.start(bot_token=Config.BOT_TOKEN)
61
+ if not Config.NO_UPDATE:
62
+ load_plugins("tgfs/plugins")
63
+
64
+ # https://github.com/LonamiWebs/Telethon/blob/59da66e105ba29eee7760538409181859c7d310d/telethon/client/downloads.py#L62
65
+ config = await client(functions.help.GetConfigRequest())
66
+ for option in config.dc_options:
67
+ if option.ip_address == client.session.server_address:
68
+ client.session.set_dc(
69
+ option.id, option.ip_address, option.port)
70
+ client.session.save()
71
+ break
72
+ me: User = await client.get_me()
73
+ Config.BOT_ID = me.id
74
+ transfer = ParallelTransferrer(client, me.id)
75
+ transfer.post_init()
76
+ multi_clients.append(transfer)
77
+ log.info("Starting Additional Clients")
78
+ await start_clients()
79
+ log.info("Starting HTTP Server")
80
+ await runner.setup()
81
+ await web.TCPSite(runner, Config.HOST, Config.PORT).start()
82
+ log.info("Version: %s", __version__)
83
+ log.info("Username: %s", me.username)
84
+ log.info("DC ID: %d", getattr(client.session, "dc_id", None))
85
+ log.info("URL: %s", Config.PUBLIC_URL)
86
+
87
+
88
+ async def stop() -> None:
89
+ log.debug("Stopping HTTP Server")
90
+ await runner.cleanup()
91
+ log.debug("Closing Telegram Client and Connections")
92
+ await client.disconnect()
93
+ log.debug("Closing Database Connection")
94
+ await DB.close()
95
+ log.info("Stopped Bot and Server")
96
+
97
+ def log_pending_tasks(include_stack: bool = False):
98
+ log_child = log.getChild("tasks")
99
+ current = asyncio.current_task()
100
+ tasks = asyncio.all_tasks()
101
+
102
+ log_child.debug("Pending asyncio tasks")
103
+
104
+ for task in tasks:
105
+ if task is current:
106
+ continue
107
+
108
+ if task.done():
109
+ continue
110
+
111
+ log_child.debug(
112
+ "Task name=%s cancelled=%s coro=%s",
113
+ task.get_name(),
114
+ task.cancelled(),
115
+ task.get_coro(),
116
+ )
117
+
118
+ if include_stack:
119
+ stack = task.get_stack()
120
+ if stack:
121
+ log_child.debug("Stack for task %s:", task.get_name())
122
+ for frame in stack:
123
+ formatted = "".join(traceback.format_stack(frame))
124
+ log_child.debug("%s", formatted)
125
+ else:
126
+ log_child.debug("No stack available for %s", task.get_name())
127
+
128
+ async def main() -> None:
129
+ try:
130
+ await start()
131
+ await client.run_until_disconnected()
132
+ finally:
133
+ log_pending_tasks(True)
134
+ await stop()
135
+ log.info("Stopped Services")
136
+
137
+ if __name__ == "__main__":
138
+ try:
139
+ asyncio.run(main())
140
+ except KeyboardInterrupt:
141
+ pass
142
+ except Exception: # pylint: disable=W0718
143
+ log.error(traceback.format_exc())
tgfs/app.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from aiohttp import web
2
+ from tgfs.routes import routes
3
+
4
+ def init_routes(app: web.Application):
5
+ app.add_routes(routes)
6
+
7
+ init_handlers = [init_routes]
8
+ def init_app() -> web.Application:
9
+ app = web.Application()
10
+
11
+ for init in init_handlers:
12
+ init(app)
13
+ return app
tgfs/config.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import logging
21
+ from os import environ
22
+ from typing import Optional
23
+
24
+ from tgfs.utils.config_utils import ConfigBase
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ try:
29
+ from dotenv import load_dotenv
30
+ load_dotenv()
31
+ except ImportError:
32
+ log.warning("python-dotenv not installed. Skipping .env loading.")
33
+
34
+ parser = argparse.ArgumentParser(
35
+ prog="tg-filestream", description="TG-FileStream server",)
36
+ parser.add_argument("--host", help="Bind host")
37
+ parser.add_argument("--port", type=int, help="Bind port")
38
+ parser.add_argument("--public-url", help="Public base URL")
39
+ parser.add_argument("--connection-limit", type=int, help="Max concurrent connections")
40
+ parser.add_argument("--db-backend", help="Database server", choices=("mysql", "mongodb"))
41
+ parser.add_argument("--no-update", action="store_true", help="Ignore Telegram Updates")
42
+ parser.add_argument("--session", help="Name for current instance", default="")
43
+ args = parser.parse_args()
44
+
45
+
46
+ class Config(ConfigBase):
47
+ MYSQL_REQUIRED = {"host", "user", "password", "db"}
48
+ MYSQL_CONFIG = {
49
+ "host": (str, None),
50
+ "port": (int, 3306),
51
+ "user": (str, None),
52
+ "password": (str, None),
53
+ "db": (str, None),
54
+ "minsize": (int, 1),
55
+ "maxsize": (int, 5),
56
+ }
57
+
58
+ MONGODB_REQUIRED = {"uri"}
59
+ MONGODB_CONFIG = {
60
+ "uri": (str, None),
61
+ "dbname": (str, "TGFS")
62
+ }
63
+
64
+ DB_LIST = {
65
+ "mysql": ("MYSQL", MYSQL_CONFIG, MYSQL_REQUIRED),
66
+ "mongodb": ("MONGODB", MONGODB_CONFIG, MONGODB_REQUIRED)
67
+ }
68
+
69
+ # ---------- Telegram ----------
70
+ API_ID: int = int(environ["API_ID"])
71
+ API_HASH: str = environ["API_HASH"]
72
+ BOT_TOKEN: str = environ["BOT_TOKEN"]
73
+
74
+ BIN_CHANNEL: int = int(environ["BIN_CHANNEL"])
75
+ TOKENS: list[str] = ConfigBase.get_multi_client_tokens()
76
+
77
+ # ---------- Server ----------
78
+ HOST: str = args.host or environ.get("HOST", "0.0.0.0")
79
+ PORT: int = args.port or ConfigBase.env_int("PORT", 8080)
80
+ PUBLIC_URL: str = args.public_url or environ.get("PUBLIC_URL", f"http://{HOST}:{PORT}")
81
+
82
+ CONNECTION_LIMIT: int = args.connection_limit or ConfigBase.env_int("CONNECTION_LIMIT", 5)
83
+
84
+ DOWNLOAD_PART_SIZE: int = ConfigBase.env_int(
85
+ "DOWNLOAD_PART_SIZE", 1024 * 1024
86
+ )
87
+
88
+ # ---------- Bot behavior ----------
89
+ NO_UPDATE: bool = args.no_update or ConfigBase.env_bool("NO_UPDATE")
90
+ SEQUENTIAL_UPDATES: bool = ConfigBase.env_bool("SEQUENTIAL_UPDATES")
91
+ FILE_INDEX_LIMIT: int = ConfigBase.env_int("FILE_INDEX_LIMIT", 10)
92
+ MAX_WARNS: int = ConfigBase.env_int("MAX_WARNS", 3)
93
+
94
+ ADMIN_IDS: set[int] = {
95
+ int(x)
96
+ for x in environ.get("ADMIN_IDS", "").split(",")
97
+ if x.strip().isdigit()
98
+ }
99
+ ALLOWED_IDS: set[int] = {
100
+ int(x)
101
+ for x in environ.get("ALLOWED_IDS", "").split(",")
102
+ if x.strip().isdigit()
103
+ }
104
+ if ALLOWED_IDS:
105
+ ALLOWED_IDS = ALLOWED_IDS | ADMIN_IDS
106
+
107
+ # ---------- DB ----------
108
+ DB_BACKEND: str = args.db_backend or environ.get("DB_BACKEND", "").lower()
109
+
110
+ if DB_BACKEND in DB_LIST:
111
+ DB_CONFIG: dict = ConfigBase.load_backend_config(*DB_LIST[DB_BACKEND])
112
+ else:
113
+ raise RuntimeError(
114
+ f"Unsupported DB_BACKEND '{DB_BACKEND}'. "
115
+ f"Valid options: {DB_LIST.keys()}"
116
+ )
117
+
118
+ SESSION_NAME: str = args.session
119
+
120
+ # ---------- Extras ----------
121
+ DEBUG: bool = ConfigBase.env_bool("DEBUG")
122
+ EXT_DEBUG: bool = ConfigBase.env_bool("EXT_DEBUG")
123
+ PATCH_PATH: str = environ.get("PATCH_PATH", "tgfs/patches")
124
+
125
+ # ---------- Security ----------
126
+ SECRET: Optional[bytes] = None
127
+ BOT_ID: Optional[int] = None
tgfs/database/__init__.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from typing import Optional
18
+ from tgfs.config import Config
19
+ from tgfs.database.mysql import MySQLDB
20
+ from tgfs.database.mongodb import MongoDB
21
+ from tgfs.database.database import BaseStorage
22
+
23
+ _BACKENDS: dict[str, type[BaseStorage]] = {
24
+ "mysql": MySQLDB,
25
+ "mongodb": MongoDB,
26
+ }
27
+
28
+ class DB:
29
+ db: Optional[BaseStorage] = None
30
+
31
+ @classmethod
32
+ async def init(cls) -> None:
33
+ if cls.db is None:
34
+ backend = Config.DB_BACKEND
35
+ db_cls = _BACKENDS[backend]
36
+ cls.db = db_cls()
37
+
38
+ await cls.db.connect(**Config.DB_CONFIG)
39
+ await cls.db.init_db()
40
+
41
+ @classmethod
42
+ async def close(cls):
43
+ if cls.db:
44
+ await cls.db.close()
45
+ cls.db = None
tgfs/database/database.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from abc import ABC, abstractmethod
18
+ from typing import AsyncGenerator, Optional
19
+
20
+ from tgfs.utils.types import SupportedType, FileInfo, FileSource, GroupInfo, InputTypeLocation, User
21
+
22
+
23
+ class BaseStorage(ABC):
24
+ """
25
+ Abstract base class for storage backends.
26
+ """
27
+
28
+ is_connected: bool
29
+
30
+ @abstractmethod
31
+ async def connect(self, **kwargs) -> None:
32
+ """
33
+ Establish a connection to the backend.
34
+
35
+ Called once during application startup.
36
+ Should raise an exception if the connection fails.
37
+ """
38
+ raise NotImplementedError
39
+
40
+ @abstractmethod
41
+ async def close(self, force: bool = False) -> None:
42
+ """
43
+ Close all open connections and release resources.
44
+
45
+ Called during graceful shutdown.
46
+ """
47
+ raise NotImplementedError
48
+
49
+ @abstractmethod
50
+ async def init_db(self) -> None:
51
+ """Create tables if they don't exist."""
52
+ raise NotImplementedError
53
+
54
+ @abstractmethod
55
+ async def add_file(self, user_id: int, file: FileInfo, source: FileSource) -> None:
56
+ raise NotImplementedError
57
+
58
+ @abstractmethod
59
+ async def update_file_restriction(self, file_id: int, status: bool) -> None:
60
+ raise NotImplementedError
61
+
62
+ @abstractmethod
63
+ async def get_file(self, file_id: int, user_id: Optional[int] = None) -> Optional[FileInfo]:
64
+ raise NotImplementedError
65
+
66
+ @abstractmethod
67
+ async def get_location(self, file: FileInfo, bot_id: int) -> Optional[InputTypeLocation]:
68
+ raise NotImplementedError
69
+
70
+ @abstractmethod
71
+ async def get_source(self, file_id: int, user_id: int) -> Optional[FileSource]:
72
+ raise NotImplementedError
73
+
74
+ @abstractmethod
75
+ async def upsert_location(self, bot_id: int, loc: InputTypeLocation) -> None:
76
+ raise NotImplementedError
77
+
78
+ @abstractmethod
79
+ async def get_files(self, user_id: int, offset: int = 0, limit: Optional[int] = None
80
+ ) -> AsyncGenerator[tuple[int, str], None]:
81
+ raise NotImplementedError
82
+
83
+ @abstractmethod
84
+ async def get_files2(self, user_id: int, file_ids: list[int], full: bool = False
85
+ ) -> AsyncGenerator[FileInfo | tuple[int, str], None]:
86
+ raise NotImplementedError
87
+
88
+ @abstractmethod
89
+ async def get_file_users(self, file_id: int, ) -> set[int]:
90
+ raise NotImplementedError
91
+
92
+ @abstractmethod
93
+ async def total_files(self, user_id: int) -> int:
94
+ raise NotImplementedError
95
+
96
+ @abstractmethod
97
+ async def delete_file(self, file_id: int) -> bool:
98
+ raise NotImplementedError
99
+
100
+ @abstractmethod
101
+ async def remove_file(self, file_id: int, user_id: int) -> bool:
102
+ raise NotImplementedError
103
+
104
+ @abstractmethod
105
+ async def create_group(self, user_id: int, name: str) -> int:
106
+ raise NotImplementedError
107
+
108
+ @abstractmethod
109
+ async def add_file_to_group(self, group_id: int, user_id: int, file_id: int, order: Optional[int] = None) -> None:
110
+ raise NotImplementedError
111
+
112
+ @abstractmethod
113
+ async def get_groups(self, user_id: int, offset: int = 0, limit: Optional[int] = None
114
+ ) -> AsyncGenerator[tuple[int, str], None]:
115
+ raise NotImplementedError
116
+
117
+ @abstractmethod
118
+ async def get_group(self, group_id: int, user_id: int) -> Optional[GroupInfo]:
119
+ raise NotImplementedError
120
+
121
+ @abstractmethod
122
+ async def delete_group(self, group_id: int, user_id: int) -> None:
123
+ raise NotImplementedError
124
+
125
+ @abstractmethod
126
+ async def update_group_name(self, group_id: int, user_id: int, name: str) -> None:
127
+ raise NotImplementedError
128
+
129
+ @abstractmethod
130
+ async def update_group_order(self, group_id: int, file_id: int, user_id: int, new_order: int) -> None:
131
+ raise NotImplementedError
132
+
133
+ @abstractmethod
134
+ async def total_groups(self, user_id: int) -> int:
135
+ raise NotImplementedError
136
+
137
+ @abstractmethod
138
+ async def get_user(self, user_id: int) -> Optional[User]:
139
+ raise NotImplementedError
140
+
141
+ @abstractmethod
142
+ async def add_user(self, user_id: int) -> bool:
143
+ raise NotImplementedError
144
+
145
+ @abstractmethod
146
+ async def upsert_user(self, user: User) -> bool:
147
+ raise NotImplementedError
148
+
149
+ @abstractmethod
150
+ async def delete_user(self, user_id: int) -> bool:
151
+ raise NotImplementedError
152
+
153
+ @abstractmethod
154
+ async def get_users(self) -> AsyncGenerator[User, None]:
155
+ raise NotImplementedError
156
+
157
+ @abstractmethod
158
+ async def count_users(self) -> int:
159
+ raise NotImplementedError
160
+
161
+ @abstractmethod
162
+ async def get_secret(self, rotate=False) -> bytes:
163
+ raise NotImplementedError
164
+
165
+ @abstractmethod
166
+ async def get_config_value(self, key: str) -> Optional[SupportedType]:
167
+ raise NotImplementedError
168
+
169
+ @abstractmethod
170
+ async def set_config_value(self, key: str, value: SupportedType) -> None:
171
+ raise NotImplementedError
tgfs/database/mongodb/__init__.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase, AsyncIOMotorCollection
18
+
19
+ from tgfs.database.database import BaseStorage
20
+
21
+ from .file import FileDB
22
+ from .group import GroupDB
23
+ from .user import UserDB
24
+ from .utils import UtilDB
25
+
26
+ class MongoDB(FileDB, GroupDB, UserDB, UtilDB, BaseStorage):
27
+ is_connected: bool = False
28
+ client: AsyncIOMotorClient
29
+ db: AsyncIOMotorDatabase
30
+ files: AsyncIOMotorCollection
31
+ groups: AsyncIOMotorCollection
32
+ users: AsyncIOMotorCollection
33
+ config: AsyncIOMotorCollection
34
+
35
+ async def connect(self, uri: str, dbname) -> None: # pylint: disable=W0221
36
+ if not self.is_connected:
37
+ self.client = AsyncIOMotorClient(uri)
38
+ self.is_connected = True
39
+ self.db = self.client[dbname]
40
+
41
+ self.files = self.db.files
42
+ self.groups = self.db.groups
43
+ self.users = self.db.users
44
+ self.config = self.db.app_config
45
+
46
+ async def close(self, force: bool = False) -> None:
47
+ if self.is_connected or force:
48
+ self.client.close()
49
+ self.is_connected = False
50
+
51
+ async def init_db(self) -> None:
52
+ await self._create_indexes()
53
+
54
+ async def _create_indexes(self) -> None:
55
+ await self.files.create_index("users.user_id")
56
+ await self.files.create_index("is_deleted")
57
+ await self.files.create_index("users.added_at")
58
+
59
+ await self.groups.create_index("user_id")
60
+ await self.groups.create_index([("user_id", 1), ("created_at", -1)])
61
+
62
+ await self.users.create_index("ban_date")
63
+ await self.users.create_index("warns")
tgfs/database/mongodb/file.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from datetime import datetime, timezone
18
+ from typing import AsyncGenerator, Optional, Union
19
+
20
+ from bson import ObjectId
21
+ from motor.motor_asyncio import AsyncIOMotorCollection, AsyncIOMotorDatabase
22
+ from telethon.tl.types import InputDocumentFileLocation, InputPhotoFileLocation
23
+
24
+ from tgfs.database.database import BaseStorage
25
+ from tgfs.utils.types import FileInfo, FileSource, InputTypeLocation
26
+
27
+
28
+ class FileDB(BaseStorage):
29
+ db: AsyncIOMotorDatabase
30
+ files: AsyncIOMotorCollection
31
+ groups: AsyncIOMotorCollection
32
+
33
+ async def add_file(self, user_id: int, file: FileInfo, source: FileSource) -> None:
34
+ await self.files.update_one(
35
+ {"_id": file.id},
36
+ {
37
+ "$set": {
38
+ "dc_id": file.dc_id,
39
+ "size": file.file_size,
40
+ "mime_type": file.mime_type,
41
+ "file_name": file.file_name,
42
+ "thumb_size": file.thumb_size,
43
+ "is_deleted": file.is_deleted,
44
+ f"users.{user_id}": {
45
+ "chat_id": source.chat_id,
46
+ "message_id": source.message_id,
47
+ "added_at": datetime.now(timezone.utc),
48
+ },
49
+ }
50
+ },
51
+ upsert=True,
52
+ )
53
+
54
+ async def update_file_restriction(self, file_id: int, status: bool) -> None:
55
+ await self.files.update_one(
56
+ {"_id": file_id},
57
+ {"$set": {"is_deleted": status}},
58
+ )
59
+
60
+ async def get_file(self, file_id: int, user_id: Optional[int] = None) -> Optional[FileInfo]:
61
+ query = {"_id": file_id}
62
+ if user_id is not None:
63
+ query[f"users.{user_id}"] = {"$exists": True}
64
+
65
+ doc = await self.files.find_one(query)
66
+ if not doc:
67
+ return None
68
+
69
+ return FileInfo(
70
+ id=doc["_id"],
71
+ dc_id=doc["dc_id"],
72
+ file_size=doc["size"],
73
+ mime_type=doc.get("mime_type"),
74
+ file_name=doc.get("file_name"),
75
+ thumb_size=doc.get("thumb_size"),
76
+ is_deleted=bool(doc.get("is_deleted", False)),
77
+ )
78
+
79
+ async def get_location(self, file: FileInfo, bot_id: int) -> Optional[InputTypeLocation]:
80
+ doc = await self.files.find_one(
81
+ {"_id": file.id, f"location.{bot_id}": {"$exists": True}},
82
+ {f"location.{bot_id}": 1},
83
+ )
84
+ if not doc:
85
+ return None
86
+
87
+ loc = doc["location"][str(bot_id)]
88
+ cls = InputPhotoFileLocation if file.thumb_size else InputDocumentFileLocation
89
+ return cls(
90
+ id=file.id,
91
+ access_hash=loc["access_hash"],
92
+ file_reference=loc["file_reference"],
93
+ thumb_size=file.thumb_size,
94
+ )
95
+
96
+ async def get_source(self, file_id: int, user_id: int) -> Optional[FileSource]:
97
+ doc = await self.files.find_one(
98
+ {"_id": file_id, f"users.{user_id}": {"$exists": True}},
99
+ {f"users.{user_id}": 1},
100
+ )
101
+ if not doc:
102
+ return None
103
+
104
+ u = doc["users"][str(user_id)]
105
+ return FileSource(
106
+ chat_id=u["chat_id"],
107
+ message_id=u["message_id"],
108
+ time=u["added_at"],
109
+ )
110
+
111
+ async def upsert_location(self, bot_id: int, loc: InputTypeLocation) -> None:
112
+ await self.files.update_one(
113
+ {"_id": loc.id},
114
+ {
115
+ "$set": {
116
+ f"location.{bot_id}": {
117
+ "access_hash": loc.access_hash,
118
+ "file_reference": loc.file_reference,
119
+ }
120
+ }
121
+ },
122
+ )
123
+
124
+ async def get_files(
125
+ self, user_id: int, offset: int = 0, limit: Optional[int] = None
126
+ ) -> AsyncGenerator[tuple[int, str], None]:
127
+ cursor = self.files.find(
128
+ {f"users.{user_id}": {"$exists": True}},
129
+ {"file_name": 1, f"users.{user_id}.added_at": 1},
130
+ ).sort(f"users.{user_id}.added_at", -1)
131
+
132
+ if offset:
133
+ cursor = cursor.skip(offset)
134
+ if limit is not None:
135
+ cursor = cursor.limit(limit)
136
+
137
+ async for doc in cursor:
138
+ yield doc["_id"], doc.get("file_name")
139
+
140
+ async def get_files2(
141
+ self, user_id: int, file_ids: list[int], full: bool = False,
142
+ ) -> AsyncGenerator[FileInfo | tuple[int, str], None]:
143
+
144
+ if not file_ids:
145
+ return
146
+
147
+ projection = None
148
+ if not full:
149
+ projection = {
150
+ "file_name": 1,
151
+ f"users.{user_id}.added_at": 1,
152
+ }
153
+
154
+ cursor = self.files.find(
155
+ {
156
+ "_id": {"$in": file_ids},
157
+ f"users.{user_id}": {"$exists": True},
158
+ },
159
+ projection,
160
+ ).sort(f"users.{user_id}.added_at", -1)
161
+
162
+ if full:
163
+ async for doc in cursor:
164
+ yield FileInfo(
165
+ id=doc["_id"],
166
+ dc_id=doc["dc_id"],
167
+ file_size=doc["size"],
168
+ mime_type=doc.get("mime_type"),
169
+ file_name=doc.get("file_name"),
170
+ thumb_size=doc.get("thumb_size"),
171
+ is_deleted=bool(doc.get("is_deleted", False)),
172
+ )
173
+ else:
174
+ async for doc in cursor:
175
+ yield doc["_id"], doc.get("file_name")
176
+
177
+ async def get_file_users(self, file_id: int) -> set[int]:
178
+ doc = await self.files.find_one(
179
+ {"_id": file_id},
180
+ {"users": 1},
181
+ )
182
+ if not doc or "users" not in doc:
183
+ return set()
184
+
185
+ return {int(uid) for uid in doc["users"].keys()}
186
+
187
+ async def total_files(self, user_id: int) -> int:
188
+ return await self.files.count_documents(
189
+ {f"users.{user_id}": {"$exists": True}}
190
+ )
191
+
192
+ async def delete_file(self, file_id: int) -> bool:
193
+ res = await self.files.delete_one({"_id": file_id})
194
+ return res.deleted_count > 0
195
+
196
+ async def _delete_file_from_group(self, user_id: int, file_id: int,) -> None:
197
+ await self.groups.update_one(
198
+ {
199
+ "user_id": user_id,
200
+ f"files.{file_id}": {"$exists": True},
201
+ },
202
+ {
203
+ "$unset": {
204
+ f"files.{file_id}": ""
205
+ }
206
+ },
207
+ )
208
+
209
+ async def remove_file(self, file_id: int, user_id: int) -> bool:
210
+ res = await self.files.update_one(
211
+ {"_id": file_id},
212
+ {"$unset": {f"users.{user_id}": ""}},
213
+ )
214
+ await self._delete_file_from_group(user_id, file_id)
215
+ return res.modified_count > 0
216
+
217
+ async def get_file_old(self, file_id: str, user_id: int = None) -> Optional[dict[str, Union[ObjectId, int]]]:
218
+ query = {"_id": ObjectId(file_id)}
219
+ if user_id is not None:
220
+ query["users_id"] = user_id
221
+
222
+ doc = await self.db.maplinks.find_one(query)
223
+ if not doc:
224
+ return None
225
+
226
+ return doc
tgfs/database/mongodb/group.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from typing import AsyncGenerator, Optional
18
+ from datetime import datetime, timezone
19
+
20
+ from pymongo import ReturnDocument
21
+ from motor.motor_asyncio import AsyncIOMotorCollection
22
+
23
+ from tgfs.database.database import BaseStorage
24
+ from tgfs.utils.types import GroupInfo
25
+
26
+ class GroupDB(BaseStorage):
27
+ groups: AsyncIOMotorCollection
28
+ config: AsyncIOMotorCollection
29
+
30
+ async def group_counter(self) -> int:
31
+ """
32
+ Generate the next sequential group ID using atomic MongoDB counter pattern.
33
+
34
+ Uses atomic find_one_and_update operation to increment a counter document
35
+ in the config collection. If the counter doesn't exist, it's created with
36
+ initial value 1 (upsert=True).
37
+
38
+ Returns:
39
+ int: Next group ID (incrementing sequence starting from 1)
40
+ """
41
+ result = await self.config.find_one_and_update(
42
+ {"_id": "group.counter"},
43
+ {"$inc": {"value": 1}},
44
+ upsert=True,
45
+ return_document=ReturnDocument.AFTER,
46
+ )
47
+ return result["value"]
48
+
49
+ async def create_group(self, user_id: int, name: str) -> int:
50
+ group_id = await self.group_counter()
51
+ await self.groups.insert_one({
52
+ "_id": group_id,
53
+ "user_id": user_id,
54
+ "name": name,
55
+ "created_at": datetime.now(timezone.utc),
56
+ "files": {},
57
+ })
58
+ return group_id
59
+
60
+ async def add_file_to_group(
61
+ self,
62
+ group_id: int,
63
+ user_id: int,
64
+ file_id: int,
65
+ order: Optional[int] = None,
66
+ ) -> None:
67
+ if order is None:
68
+ doc = await self.groups.find_one(
69
+ {"_id": group_id, "user_id": user_id},
70
+ {"files": 1},
71
+ )
72
+
73
+ order = 1
74
+ if doc and "files" in doc:
75
+ order = len(doc["files"])+1
76
+
77
+ await self.groups.update_one(
78
+ {"_id": group_id, "user_id": user_id},
79
+ {
80
+ "$set": {
81
+ f"files.{file_id}": {"order": order}
82
+ }
83
+ },
84
+ upsert=False,
85
+ )
86
+
87
+ async def get_groups(
88
+ self, user_id: int, offset: int = 0, limit: Optional[int] = None
89
+ ) -> AsyncGenerator[tuple[int, str], None]:
90
+
91
+ cursor = (
92
+ self.groups.find(
93
+ {"user_id": user_id},
94
+ {"name": 1},
95
+ )
96
+ .sort("created_at", -1)
97
+ .skip(offset)
98
+ )
99
+
100
+ if limit is not None:
101
+ cursor = cursor.limit(limit)
102
+
103
+ async for doc in cursor:
104
+ yield doc["_id"], doc["name"]
105
+
106
+ async def get_group(self, group_id: int, user_id: int) -> Optional[GroupInfo]:
107
+ doc = await self.groups.find_one(
108
+ {"_id": group_id, "user_id": user_id}
109
+ )
110
+ if not doc:
111
+ return None
112
+
113
+ files = [
114
+ int(fid)
115
+ for fid, _ in sorted(
116
+ doc.get("files", {}).items(),
117
+ key=lambda i: i[1]["order"]
118
+ )
119
+ ]
120
+
121
+ return GroupInfo(
122
+ group_id=doc["_id"],
123
+ user_id=doc["user_id"],
124
+ name=doc["name"],
125
+ created_at=doc["created_at"],
126
+ files=files
127
+ )
128
+
129
+ async def delete_group(self, group_id: int, user_id: int) -> None:
130
+ await self.groups.delete_one(
131
+ {"_id": group_id, "user_id": user_id}
132
+ )
133
+
134
+ async def update_group_name(self, group_id: int, user_id: int, name: str) -> None:
135
+ await self.groups.update_one(
136
+ {"_id": group_id, "user_id": user_id},
137
+ {"$set": {"name": name}},
138
+ )
139
+
140
+ async def update_group_order(
141
+ self, group_id: int, file_id: int, user_id: int, new_order: int
142
+ ) -> None:
143
+ await self.groups.update_one(
144
+ {
145
+ "_id": group_id,
146
+ "user_id": user_id,
147
+ f"files.{file_id}": {"$exists": True},
148
+ },
149
+ {
150
+ "$set": {
151
+ f"files.{file_id}.order": new_order
152
+ }
153
+ },
154
+ )
155
+
156
+ async def total_groups(self, user_id: int) -> int:
157
+ return await self.groups.count_documents(
158
+ {"user_id": user_id}
159
+ )
tgfs/database/mongodb/user.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from typing import Optional, AsyncGenerator
18
+ from datetime import datetime, timezone
19
+
20
+ from motor.motor_asyncio import AsyncIOMotorCollection
21
+
22
+ from tgfs.database.database import BaseStorage
23
+ from tgfs.utils.types import Status, User
24
+
25
+
26
+ class UserDB(BaseStorage):
27
+ users: AsyncIOMotorCollection
28
+
29
+ async def get_user(self, user_id: int) -> Optional[User]:
30
+ doc = await self.users.find_one({"_id": user_id})
31
+ if not doc:
32
+ return None
33
+
34
+ return User(
35
+ user_id=doc["_id"],
36
+ join_date=doc["join_date"],
37
+ ban_date=doc.get("ban_date"),
38
+ warns=doc["warns"],
39
+ preferred_lang=doc["preferred_lang"],
40
+ curt_op=Status(doc["curt_op"]),
41
+ op_id=doc["op_id"],
42
+ )
43
+
44
+ async def add_user(self, user_id: int) -> bool:
45
+ try:
46
+ await self.users.insert_one({
47
+ "_id": user_id,
48
+ "join_date": datetime.now(timezone.utc),
49
+ "ban_date": None,
50
+ "warns": 0,
51
+ "preferred_lang": "en",
52
+ "curt_op": 0,
53
+ "op_id": 0,
54
+ })
55
+ return True
56
+ except Exception: # pylint: disable=W0718
57
+ return False
58
+
59
+ async def upsert_user(self, user: User) -> bool:
60
+ await self.users.update_one(
61
+ {"_id": user.user_id},
62
+ {
63
+ "$set": {
64
+ "join_date": user.join_date,
65
+ "ban_date": user.ban_date,
66
+ "warns": user.warns,
67
+ "preferred_lang": user.preferred_lang,
68
+ "curt_op": user.curt_op.value,
69
+ "op_id": user.op_id,
70
+ }
71
+ },
72
+ upsert=True,
73
+ )
74
+ return True
75
+
76
+ async def delete_user(self, user_id: int) -> bool:
77
+ res = await self.users.delete_one({"_id": user_id})
78
+ return res.deleted_count > 0
79
+
80
+ async def get_users(self) -> AsyncGenerator[User, None]:
81
+ cursor = self.users.find({})
82
+
83
+ async for doc in cursor:
84
+ yield User(
85
+ user_id=doc["_id"],
86
+ join_date=doc["join_date"],
87
+ ban_date=doc.get("ban_date"),
88
+ warns=doc["warns"],
89
+ preferred_lang=doc["preferred_lang"],
90
+ curt_op=doc["curt_op"],
91
+ op_id=doc["op_id"],
92
+ )
93
+
94
+ async def count_users(self) -> int:
95
+ return await self.users.count_documents({})
tgfs/database/mongodb/utils.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import os
18
+
19
+ from motor.motor_asyncio import AsyncIOMotorCollection
20
+ from bson.binary import Binary
21
+
22
+ from tgfs.database.database import BaseStorage
23
+
24
+
25
+ class UtilDB(BaseStorage):
26
+ config: AsyncIOMotorCollection
27
+ async def get_secret(self, rotate: bool = False) -> bytes:
28
+ if not rotate:
29
+ doc = await self.config.find_one({"_id": "link.secret"})
30
+ if doc:
31
+ return doc["value"]
32
+
33
+ secret = os.urandom(32)
34
+
35
+ await self.config.update_one(
36
+ {"_id": "link.secret"},
37
+ {"$set": {"value": Binary(secret)}},
38
+ upsert=True,
39
+ )
40
+
41
+ return secret
42
+
43
+ async def get_config_value(self, key: str):
44
+ doc = await self.config.find_one({"_id": key})
45
+ return doc["value"] if doc else None
46
+
47
+ async def set_config_value(self, key: str, value):
48
+ await self.config.update_one(
49
+ {"_id": key},
50
+ {"$set": {"value": value}},
51
+ upsert=True,
52
+ )
tgfs/database/mysql/__init__.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from pathlib import Path
18
+ import warnings
19
+
20
+ import aiomysql
21
+
22
+ from .file import FileDB
23
+ from .user import UserDB
24
+ from .group import GroupDB
25
+ from .utils import UtilDB
26
+
27
+
28
+ def read_sql_file(path: str) -> list[str]:
29
+ sql = Path(path).read_text(encoding="utf-8")
30
+ statements = []
31
+ buffer = []
32
+
33
+ for line in sql.splitlines():
34
+ line = line.strip()
35
+ if not line or line.startswith("--"):
36
+ continue
37
+
38
+ buffer.append(line)
39
+ if line.endswith(";"):
40
+ statements.append(" ".join(buffer))
41
+ buffer.clear()
42
+
43
+ return statements
44
+
45
+ class MySQLDB(FileDB, GroupDB, UserDB, UtilDB):
46
+ _pool: aiomysql.Pool
47
+ is_connected: bool = False
48
+
49
+ async def connect(self, *, host: str, port: int = 3306, user: str, password: str, # pylint: disable=W0221
50
+ db: str, minsize: int = 1, maxsize: int = 10, autocommit: bool = False,
51
+ connect_timeout: int = 10) -> None:
52
+ if not self.is_connected:
53
+ self._pool = await aiomysql.create_pool(
54
+ host=host, port=port, user=user, password=password, db=db,
55
+ minsize=minsize, maxsize=maxsize, autocommit=autocommit,
56
+ connect_timeout=connect_timeout, charset="utf8mb4"
57
+ )
58
+ self.is_connected = True
59
+
60
+ async def close(self, force: bool = False) -> None:
61
+ if self.is_connected or force:
62
+ self._pool.close()
63
+ await self._pool.wait_closed()
64
+ self.is_connected = False
65
+
66
+ async def init_db(self) -> None:
67
+ statements = read_sql_file("tgfs/database/mysql/schema.sql")
68
+
69
+ async with self._pool.acquire() as conn:
70
+ warnings.filterwarnings('ignore', module=r"aiomysql")
71
+ async with conn.cursor() as cur:
72
+ for stmt in statements:
73
+ await cur.execute(stmt)
74
+ await conn.commit()
75
+ warnings.filterwarnings('default', module=r"aiomysql")
tgfs/database/mysql/file.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from typing import AsyncGenerator, Optional
18
+
19
+ import aiomysql
20
+ from telethon.tl.types import InputDocumentFileLocation, InputPhotoFileLocation
21
+
22
+ from tgfs.database.database import BaseStorage
23
+ from tgfs.utils.types import FileSource, FileInfo, InputTypeLocation
24
+
25
+
26
+ class FileDB(BaseStorage):
27
+ _pool: aiomysql.Pool
28
+
29
+ async def add_file(self, user_id: int, file: FileInfo, source: FileSource) -> None:
30
+ async with self._pool.acquire() as conn:
31
+ async with conn.cursor() as cur:
32
+ try:
33
+ await cur.execute(
34
+ """
35
+ INSERT INTO TGFILE (id, dc_id, size, mime_type, file_name, thumb_size, is_deleted)
36
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
37
+ ON DUPLICATE KEY UPDATE
38
+ dc_id = VALUES(dc_id),
39
+ size = VALUES(size),
40
+ mime_type = VALUES(mime_type),
41
+ file_name = VALUES(file_name),
42
+ thumb_size = VALUES(thumb_size),
43
+ is_deleted = VALUES(is_deleted)
44
+ """,
45
+ (
46
+ file.id, file.dc_id, file.file_size, file.mime_type,
47
+ file.file_name, file.thumb_size,
48
+ file.is_deleted
49
+ )
50
+ )
51
+ await cur.execute(
52
+ """
53
+ INSERT INTO USER_FILE (user_id, id, source_chat_id, source_msg_id)
54
+ VALUES (%s, %s, %s, %s)
55
+ ON DUPLICATE KEY UPDATE
56
+ source_chat_id = COALESCE(VALUES(source_chat_id), source_chat_id),
57
+ source_msg_id = COALESCE(VALUES(source_msg_id), source_msg_id)
58
+ """,
59
+ (user_id, file.id, source.chat_id, source.message_id)
60
+ )
61
+ await conn.commit()
62
+ except Exception:
63
+ await conn.rollback()
64
+ raise
65
+
66
+ async def update_file_restriction(self, file_id: int, status: bool) -> None:
67
+ async with self._pool.acquire() as conn:
68
+ async with conn.cursor() as cur:
69
+ try:
70
+ await cur.execute(
71
+ """
72
+ UPDATE TGFILE SET is_deleted = %s WHERE id = %s
73
+ """,
74
+ (status, file_id)
75
+ )
76
+ await conn.commit()
77
+ except Exception:
78
+ await conn.rollback()
79
+ raise
80
+
81
+ async def get_file(self, file_id: int, user_id: Optional[int] = None) -> Optional[FileInfo]:
82
+ async with self._pool.acquire() as conn:
83
+ async with conn.cursor(aiomysql.DictCursor) as cur:
84
+ if user_id is None:
85
+ await cur.execute(
86
+ """
87
+ SELECT f.id AS file_id,
88
+ f.dc_id,
89
+ f.size AS file_size,
90
+ f.mime_type,
91
+ f.file_name,
92
+ f.thumb_size,
93
+ f.is_deleted
94
+ FROM TGFILE f
95
+ WHERE f.id = %s
96
+ LIMIT 1
97
+ """,
98
+ (file_id, )
99
+ )
100
+ else:
101
+ await cur.execute(
102
+ """
103
+ SELECT f.id AS file_id,
104
+ f.dc_id,
105
+ f.size AS file_size,
106
+ f.mime_type,
107
+ f.file_name,
108
+ f.thumb_size,
109
+ f.is_deleted
110
+ FROM TGFILE f
111
+ WHERE f.id = %s
112
+ AND EXISTS (
113
+ SELECT 1
114
+ FROM USER_FILE uf
115
+ WHERE uf.id = f.id
116
+ AND uf.user_id = %s
117
+ )
118
+ LIMIT 1
119
+ """,
120
+ (file_id, user_id)
121
+ )
122
+
123
+ row = await cur.fetchone()
124
+ if not row:
125
+ return None
126
+
127
+ return FileInfo(
128
+ id=int(row["file_id"]),
129
+ dc_id=int(row["dc_id"]),
130
+ file_size=int(row["file_size"]),
131
+ mime_type=row["mime_type"],
132
+ file_name=row["file_name"],
133
+ thumb_size=row["thumb_size"],
134
+ is_deleted=bool(row["is_deleted"]),
135
+ )
136
+
137
+ async def get_location(self, file: FileInfo, bot_id: int) -> Optional[InputTypeLocation]:
138
+ async with self._pool.acquire() as conn:
139
+ async with conn.cursor(aiomysql.DictCursor) as cur:
140
+ await cur.execute(
141
+ """
142
+ SELECT access_hash, file_reference
143
+ FROM FILE_LOCATION
144
+ WHERE id = %s and bot_id = %s
145
+ LIMIT 1
146
+ """,
147
+ (file.id, bot_id)
148
+ )
149
+ row = await cur.fetchone()
150
+ if not row:
151
+ return None
152
+
153
+ cls = InputPhotoFileLocation if file.thumb_size else InputDocumentFileLocation
154
+ return cls(
155
+ id=file.id,
156
+ access_hash=int(row["access_hash"]),
157
+ file_reference=row["file_reference"],
158
+ thumb_size=file.thumb_size
159
+ )
160
+
161
+ async def get_source(self, file_id: int, user_id: int) -> Optional[FileSource]:
162
+ async with self._pool.acquire() as conn:
163
+ async with conn.cursor(aiomysql.DictCursor) as cur:
164
+ await cur.execute(
165
+ """
166
+ SELECT source_chat_id, source_msg_id, added_at
167
+ FROM USER_FILE
168
+ WHERE id = %s and user_id = %s
169
+ LIMIT 1
170
+ """,
171
+ (file_id, user_id)
172
+ )
173
+ row = await cur.fetchone()
174
+ if not row:
175
+ return None
176
+ return FileSource(
177
+ chat_id=int(row["source_chat_id"]),
178
+ message_id=int(row["source_msg_id"]),
179
+ time=row["added_at"]
180
+ )
181
+
182
+ async def upsert_location(self, bot_id: int, loc: InputTypeLocation) -> None:
183
+ async with self._pool.acquire() as conn:
184
+ async with conn.cursor() as cur:
185
+ try:
186
+ await cur.execute(
187
+ """
188
+ INSERT INTO FILE_LOCATION (bot_id, id, access_hash, file_reference)
189
+ VALUES (%s, %s, %s, %s)
190
+ ON DUPLICATE KEY UPDATE
191
+ access_hash = VALUES(access_hash),
192
+ file_reference = VALUES(file_reference)
193
+ """,
194
+ (bot_id, loc.id, loc.access_hash, loc.file_reference)
195
+ )
196
+ await conn.commit()
197
+ except Exception:
198
+ await conn.rollback()
199
+ raise
200
+
201
+ async def get_files(self, user_id: int, offset: int = 0, limit: Optional[int] = None
202
+ ) -> AsyncGenerator[tuple[int, str], None]:
203
+
204
+ base_sql = """
205
+ SELECT f.id AS file_id, f.file_name
206
+ FROM TGFILE f
207
+ JOIN USER_FILE uf ON f.id = uf.id
208
+ WHERE uf.user_id = %s
209
+ ORDER BY uf.added_at DESC
210
+ """
211
+
212
+ params = [user_id]
213
+
214
+ if limit is not None:
215
+ base_sql += " LIMIT %s OFFSET %s"
216
+ params.extend([limit, offset])
217
+
218
+ async with self._pool.acquire() as conn:
219
+ async with conn.cursor(aiomysql.SSCursor) as cur:
220
+ await cur.execute(base_sql, params)
221
+
222
+ async for row in cur:
223
+ file_id, file_name = row
224
+ yield int(file_id), str(file_name)
225
+
226
+ async def get_files2(self, user_id: int, file_ids: list[int], full: bool = False,
227
+ ) -> AsyncGenerator[FileInfo | tuple[int, str], None]:
228
+
229
+ if not file_ids:
230
+ return
231
+
232
+ placeholders = ",".join(["%s"] * len(file_ids))
233
+
234
+ if full:
235
+ select_clause = "f.*"
236
+ else:
237
+ select_clause = "f.id AS file_id, f.file_name"
238
+
239
+ base_sql = f"""
240
+ SELECT {select_clause}
241
+ FROM TGFILE f
242
+ JOIN USER_FILE uf ON f.id = uf.id
243
+ WHERE uf.user_id = %s
244
+ AND f.id IN ({placeholders})
245
+ ORDER BY uf.added_at DESC
246
+ """
247
+
248
+ params = [user_id] + file_ids
249
+
250
+ async with self._pool.acquire() as conn:
251
+ async with conn.cursor(aiomysql.SSCursor) as cur:
252
+ await cur.execute(base_sql, params)
253
+
254
+ if full:
255
+ columns = [col[0] for col in cur.description]
256
+
257
+ async for raw_row in cur:
258
+ row = dict(zip(columns, raw_row))
259
+ yield FileInfo(
260
+ id=int(row["id"]),
261
+ dc_id=int(row["dc_id"]),
262
+ file_size=int(row["size"]),
263
+ mime_type=row["mime_type"],
264
+ file_name=row["file_name"],
265
+ thumb_size=row["thumb_size"],
266
+ is_deleted=bool(row["is_deleted"]),
267
+ )
268
+ else:
269
+ async for row in cur:
270
+ file_id, file_name = row
271
+ yield int(file_id), str(file_name)
272
+
273
+ async def get_file_users(self, file_id: int, ) -> set[int]:
274
+ async with self._pool.acquire() as conn:
275
+ async with conn.cursor() as cur:
276
+ await cur.execute(
277
+ """
278
+ SELECT user_id
279
+ FROM USER_FILE
280
+ WHERE id = %s
281
+ """,
282
+ (file_id)
283
+ )
284
+
285
+ rows = await cur.fetchall()
286
+ return {col[0] for col in rows}
287
+
288
+ async def total_files(self, user_id: int) -> int:
289
+ async with self._pool.acquire() as conn:
290
+ async with conn.cursor() as cur:
291
+ await cur.execute(
292
+ """
293
+ SELECT COUNT(*)
294
+ FROM USER_FILE
295
+ WHERE user_id = %s
296
+ """,
297
+ (user_id,)
298
+ )
299
+
300
+ row = await cur.fetchone()
301
+ return int(row[0]) if row else 0
302
+
303
+ async def delete_file(self, file_id: int) -> bool:
304
+ async with self._pool.acquire() as conn:
305
+ async with conn.cursor(aiomysql.DictCursor) as cur:
306
+ try:
307
+ await cur.execute(
308
+ """
309
+ DELETE FROM TGFILE
310
+ WHERE id = %s
311
+ LIMIT 1
312
+ """,
313
+ (file_id)
314
+ )
315
+ deleted = bool(cur.rowcount > 0)
316
+ await conn.commit()
317
+ return deleted
318
+ except Exception:
319
+ await conn.rollback()
320
+ raise
321
+
322
+ async def remove_file(self, file_id: int, user_id: int) -> bool:
323
+ async with self._pool.acquire() as conn:
324
+ async with conn.cursor(aiomysql.DictCursor) as cur:
325
+ try:
326
+ await cur.execute(
327
+ """
328
+ DELETE FROM USER_FILE
329
+ WHERE id = %s AND user_id = %s
330
+ LIMIT 1
331
+ """,
332
+ (file_id, user_id)
333
+ )
334
+ deleted = bool(cur.rowcount > 0)
335
+ await conn.commit()
336
+ return deleted
337
+ except Exception:
338
+ await conn.rollback()
339
+ raise
tgfs/database/mysql/group.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import asyncio
18
+ from typing import AsyncGenerator, Optional
19
+
20
+ import aiomysql
21
+
22
+ from tgfs.database.database import BaseStorage
23
+ from tgfs.utils.types import GroupInfo
24
+
25
+ class GroupDB(BaseStorage):
26
+ _list_lock = asyncio.Lock()
27
+ _pool: aiomysql.Pool
28
+
29
+ async def create_group(self, user_id: int, name: str) -> int:
30
+ async with self._pool.acquire() as conn:
31
+ async with conn.cursor() as cur:
32
+ try:
33
+ await cur.execute(
34
+ """
35
+ INSERT INTO FILE_GROUP (user_id, name)
36
+ VALUES (%s, %s)
37
+ """,
38
+ (user_id, name)
39
+ )
40
+ group_id = cur.lastrowid
41
+ await conn.commit()
42
+ return group_id
43
+ except Exception:
44
+ await conn.rollback()
45
+ raise
46
+
47
+ async def add_file_to_group(self, group_id: int, user_id: int, file_id: int, order: Optional[int] = None) -> None:
48
+ async with self._list_lock:
49
+ async with self._pool.acquire() as conn:
50
+ async with conn.cursor() as cur:
51
+ try:
52
+ if order is None:
53
+ await cur.execute(
54
+ """
55
+ SELECT COALESCE(MAX(order_index), 0) + 1
56
+ FROM FILE_GROUP_FILE
57
+ WHERE group_id = %s
58
+ FOR UPDATE
59
+ """,
60
+ (group_id,)
61
+ )
62
+ row = await cur.fetchone()
63
+ order = int(row[0]) if row else 1
64
+
65
+ await cur.execute(
66
+ """
67
+ INSERT INTO FILE_GROUP_FILE (group_id, user_id, id, order_index)
68
+ VALUES (%s, %s, %s, %s)
69
+ ON DUPLICATE KEY UPDATE
70
+ order_index = VALUES(order_index)
71
+ """,
72
+ (group_id, user_id, file_id, order)
73
+ )
74
+
75
+ await conn.commit()
76
+ except Exception:
77
+ await conn.rollback()
78
+ raise
79
+
80
+ async def get_groups(self, user_id: int, offset: int = 0, limit: Optional[int] = None
81
+ ) -> AsyncGenerator[tuple[int, str], None]:
82
+ base_sql = """
83
+ SELECT group_id, name
84
+ FROM FILE_GROUP
85
+ WHERE user_id = %s
86
+ ORDER BY created_at DESC
87
+ """
88
+ params = [user_id]
89
+
90
+ if limit is not None:
91
+ base_sql += " LIMIT %s OFFSET %s"
92
+ params.extend([limit, offset])
93
+
94
+ async with self._pool.acquire() as conn:
95
+ async with conn.cursor(aiomysql.SSCursor) as cur:
96
+ await cur.execute(base_sql, params)
97
+ async for row in cur:
98
+ group_id, name = row
99
+ yield int(group_id), str(name)
100
+
101
+ async def get_group(self, group_id: int, user_id: int) -> Optional[GroupInfo]:
102
+ async with self._pool.acquire() as conn:
103
+ async with conn.cursor(aiomysql.DictCursor) as cur:
104
+ await cur.execute(
105
+ """
106
+ SELECT group_id, user_id, name, created_at
107
+ FROM FILE_GROUP
108
+ WHERE group_id = %s AND user_id = %s
109
+ LIMIT 1
110
+ """,
111
+ (group_id, user_id)
112
+ )
113
+ row = await cur.fetchone()
114
+ if not row:
115
+ return None
116
+
117
+ gi = GroupInfo(
118
+ group_id=int(row["group_id"]),
119
+ user_id=int(row["user_id"]),
120
+ name=row["name"],
121
+ created_at=row.get("created_at"),
122
+ files=[]
123
+ )
124
+
125
+ await cur.execute(
126
+ """
127
+ SELECT id
128
+ FROM FILE_GROUP_FILE
129
+ WHERE group_id = %s
130
+ ORDER BY order_index ASC
131
+ """,
132
+ (group_id,)
133
+ )
134
+ rows = await cur.fetchall()
135
+ if not rows:
136
+ return gi
137
+
138
+ gi.files.extend([int(r["id"]) for r in rows])
139
+ return gi
140
+
141
+ async def delete_group(self, group_id: int, user_id: int) -> None:
142
+ async with self._pool.acquire() as conn:
143
+ async with conn.cursor() as cur:
144
+ try:
145
+ await cur.execute(
146
+ """
147
+ DELETE FROM FILE_GROUP
148
+ WHERE group_id = %s AND user_id = %s
149
+ """,
150
+ (group_id, user_id)
151
+ )
152
+ await conn.commit()
153
+ except Exception:
154
+ await conn.rollback()
155
+ raise
156
+
157
+ async def update_group_name(self, group_id: int, user_id: int, name: str) -> None:
158
+ async with self._pool.acquire() as conn:
159
+ async with conn.cursor() as cur:
160
+ try:
161
+ await cur.execute(
162
+ """
163
+ UPDATE FILE_GROUP
164
+ SET name = %s
165
+ WHERE group_id = %s AND user_id = %s
166
+ """,
167
+ (name, group_id, user_id)
168
+ )
169
+ await conn.commit()
170
+ except Exception:
171
+ await conn.rollback()
172
+ raise
173
+
174
+ async def update_group_order(self, group_id: int, file_id: int, user_id: int, new_order: int) -> None:
175
+ async with self._pool.acquire() as conn:
176
+ async with conn.cursor() as cur:
177
+ try:
178
+ await cur.execute(
179
+ """
180
+ UPDATE FILE_GROUP_FILE gff
181
+ JOIN FILE_GROUP fg
182
+ ON fg.group_id = gff.group_id
183
+ SET gff.order_index = %s
184
+ WHERE gff.group_id = %s
185
+ AND gff.id = %s
186
+ AND fg.user_id = %s
187
+ """,
188
+ (new_order, group_id, file_id, user_id)
189
+ )
190
+ await conn.commit()
191
+ except Exception:
192
+ await conn.rollback()
193
+ raise
194
+ async def total_groups(self, user_id: int) -> int:
195
+ async with self._pool.acquire() as conn:
196
+ async with conn.cursor() as cur:
197
+ await cur.execute(
198
+ """
199
+ SELECT COUNT(*)
200
+ FROM FILE_GROUP
201
+ WHERE user_id = %s
202
+ """,
203
+ (user_id)
204
+ )
205
+
206
+ row = await cur.fetchone()
207
+ return int(row[0]) if row else 0
tgfs/database/mysql/schema.sql ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS TGFILE (
2
+ id BIGINT UNSIGNED PRIMARY KEY,
3
+ dc_id TINYINT UNSIGNED NOT NULL,
4
+ size BIGINT UNSIGNED NOT NULL,
5
+ mime_type VARCHAR(255),
6
+ file_name VARCHAR(255),
7
+ thumb_size CHAR(1),
8
+ is_deleted BOOLEAN DEFAULT FALSE
9
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
10
+
11
+ CREATE TABLE IF NOT EXISTS FILE_LOCATION (
12
+ bot_id BIGINT UNSIGNED NOT NULL,
13
+ id BIGINT UNSIGNED NOT NULL,
14
+ access_hash BIGINT NULL,
15
+ file_reference BLOB NULL,
16
+ PRIMARY KEY (bot_id, id),
17
+ CONSTRAINT fk_file_ids_files FOREIGN KEY (id) REFERENCES TGFILE(id) ON DELETE CASCADE
18
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
19
+
20
+ CREATE TABLE IF NOT EXISTS TGUSER (
21
+ user_id BIGINT UNSIGNED PRIMARY KEY,
22
+ join_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
23
+ ban_date TIMESTAMP NULL,
24
+ warns TINYINT UNSIGNED NOT NULL DEFAULT 0,
25
+ preferred_lang CHAR(2) NOT NULL DEFAULT 'en',
26
+ curt_op TINYINT UNSIGNED DEFAULT 0,
27
+ op_id BIGINT DEFAULT 0
28
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
29
+
30
+ CREATE TABLE IF NOT EXISTS USER_FILE (
31
+ user_id BIGINT UNSIGNED NOT NULL,
32
+ id BIGINT UNSIGNED NOT NULL,
33
+ source_chat_id BIGINT NULL,
34
+ source_msg_id BIGINT UNSIGNED NULL,
35
+ added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
36
+ PRIMARY KEY (user_id, id),
37
+ CONSTRAINT fk_user_files_users FOREIGN KEY (user_id) REFERENCES TGUSER(user_id) ON DELETE CASCADE,
38
+ CONSTRAINT fk_user_files_files FOREIGN KEY (id) REFERENCES TGFILE(id) ON DELETE RESTRICT
39
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
40
+
41
+ CREATE TABLE IF NOT EXISTS FILE_GROUP (
42
+ group_id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
43
+ user_id BIGINT UNSIGNED NOT NULL,
44
+ name VARCHAR(255) NOT NULL,
45
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
46
+ CONSTRAINT fk_file_group_owner FOREIGN KEY (user_id) REFERENCES TGUSER(user_id) ON DELETE CASCADE
47
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
48
+
49
+ CREATE TABLE IF NOT EXISTS FILE_GROUP_FILE (
50
+ group_id BIGINT UNSIGNED NOT NULL,
51
+ user_id BIGINT UNSIGNED NOT NULL,
52
+ id BIGINT UNSIGNED NOT NULL,
53
+ order_index INT UNSIGNED NOT NULL DEFAULT 0,
54
+ PRIMARY KEY (group_id, user_id, id),
55
+ CONSTRAINT fk_fg_group FOREIGN KEY (group_id) REFERENCES FILE_GROUP(group_id) ON DELETE CASCADE,
56
+ CONSTRAINT fk_fg_user_file FOREIGN KEY (user_id, id) REFERENCES USER_FILE(user_id, id) ON DELETE CASCADE
57
+ ) ENGINE=InnoDB;
58
+
59
+ CREATE TABLE IF NOT EXISTS APP_CONFIG (
60
+ k VARCHAR(64) PRIMARY KEY,
61
+ v BLOB NOT NULL,
62
+ type ENUM('bytes', 'bool', 'int', 'str', 'json') NOT NULL
63
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
tgfs/database/mysql/user.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ from typing import AsyncGenerator, Optional
18
+
19
+ import aiomysql
20
+
21
+ from tgfs.database.database import BaseStorage
22
+ from tgfs.utils.types import User
23
+
24
+
25
+ class UserDB(BaseStorage):
26
+ _pool: aiomysql.Pool
27
+
28
+ async def get_user(self, user_id: int) -> Optional[User]:
29
+ async with self._pool.acquire() as conn:
30
+ async with conn.cursor(aiomysql.DictCursor) as cur:
31
+ await cur.execute(
32
+ """
33
+ SELECT user_id, join_date, ban_date, warns, preferred_lang, curt_op, op_id
34
+ FROM TGUSER WHERE user_id = %s
35
+ """,
36
+ (user_id,)
37
+ )
38
+ row = await cur.fetchone()
39
+ if not row:
40
+ return None
41
+ return User.from_row(row)
42
+
43
+ async def add_user(self, user_id: int) -> bool:
44
+ async with self._pool.acquire() as conn:
45
+ async with conn.cursor() as cur:
46
+ try:
47
+ await cur.execute(
48
+ "INSERT IGNORE INTO TGUSER (user_id) VALUES (%s)",
49
+ (user_id,)
50
+ )
51
+ inserted = cur.rowcount > 0
52
+ await conn.commit()
53
+ return bool(inserted)
54
+ except Exception:
55
+ await conn.rollback()
56
+ raise
57
+
58
+ async def upsert_user(self, user: User) -> bool:
59
+ async with self._pool.acquire() as conn:
60
+ async with conn.cursor() as cur:
61
+ try:
62
+ await cur.execute(
63
+ """
64
+ INSERT INTO TGUSER (user_id, join_date, ban_date, warns, preferred_lang, curt_op, op_id)
65
+ VALUES (%s, %s, %s, %s, %s, %s, %s) AS new
66
+ ON DUPLICATE KEY UPDATE
67
+ join_date = new.join_date,
68
+ ban_date = new.ban_date,
69
+ warns = new.warns,
70
+ preferred_lang = new.preferred_lang,
71
+ curt_op = new.curt_op,
72
+ op_id = new.op_id
73
+ """,
74
+ (user.user_id, user.join_date, user.ban_date, user.warns,
75
+ user.preferred_lang, user.curt_op.value, user.op_id)
76
+ )
77
+ await conn.commit()
78
+ return True
79
+ except Exception:
80
+ await conn.rollback()
81
+ raise
82
+
83
+ async def delete_user(self, user_id: int) -> bool:
84
+ async with self._pool.acquire() as conn:
85
+ async with conn.cursor() as cur:
86
+ try:
87
+ await cur.execute("DELETE FROM TGUSER WHERE user_id = %s", (user_id,))
88
+ deleted = cur.rowcount > 0
89
+ await conn.commit()
90
+ return bool(deleted)
91
+ except Exception:
92
+ await conn.rollback()
93
+ raise
94
+
95
+ async def get_users(self) -> AsyncGenerator[User, None]:
96
+ async with self._pool.acquire() as conn:
97
+ async with conn.cursor(aiomysql.DictCursor) as cur:
98
+ await cur.execute(
99
+ "SELECT user_id, join_date, ban_date, warns, preferred_lang, curt_op, op_id FROM TGUSER"
100
+ )
101
+
102
+ while True:
103
+ row = await cur.fetchone()
104
+ if not row:
105
+ break
106
+
107
+ yield User.from_row(row)
108
+
109
+ async def count_users(self) -> int:
110
+ async with self._pool.acquire() as conn:
111
+ async with conn.cursor() as cur:
112
+ await cur.execute("SELECT COUNT(*) FROM TGUSER")
113
+ (count,) = await cur.fetchone()
114
+ return int(count)
tgfs/database/mysql/utils.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import os
18
+
19
+ import json
20
+ from typing import Optional
21
+
22
+ import aiomysql
23
+
24
+ from tgfs.database.database import BaseStorage
25
+ from tgfs.utils.types import SupportedType
26
+
27
+ def encode_value(value: SupportedType) -> tuple[bytes, str]:
28
+ if isinstance(value, bytes):
29
+ return value, "bytes"
30
+
31
+ if isinstance(value, str):
32
+ return value.encode("utf-8"), "str"
33
+
34
+ if isinstance(value, bool):
35
+ return (b"\x01" if value else b"\x00"), "bool"
36
+
37
+ if isinstance(value, int):
38
+ size = max(1, (value.bit_length() + 7) // 8)
39
+ return value.to_bytes(size, "big", signed=True), "int"
40
+
41
+ if isinstance(value, (dict, list)):
42
+ return json.dumps(value, separators=(",", ":")).encode("utf-8"), "json"
43
+
44
+ raise TypeError(f"Unsupported type: {type(value)}")
45
+
46
+ def decode_value(data: bytes, vtype: str) -> SupportedType:
47
+ if vtype == "bytes":
48
+ return data
49
+
50
+ if vtype == "str":
51
+ return data.decode("utf-8")
52
+
53
+ if vtype == "bool":
54
+ return data != b"\x00"
55
+
56
+ if vtype == "int":
57
+ return int.from_bytes(data, "big", signed=True)
58
+
59
+ if vtype == "json":
60
+ return json.loads(data.decode("utf-8"))
61
+
62
+ raise ValueError(f"Unknown type: {vtype}")
63
+
64
+
65
+ class UtilDB(BaseStorage):
66
+ _pool: aiomysql.Pool
67
+
68
+ async def get_secret(self, rotate=False) -> bytes:
69
+ async with self._pool.acquire() as conn:
70
+ async with conn.cursor() as cur:
71
+ if not rotate:
72
+ await cur.execute(
73
+ "SELECT v FROM APP_CONFIG WHERE k = %s",
74
+ ("link.secret",)
75
+ )
76
+ row: bytes = await cur.fetchone()
77
+ if row:
78
+ return row[0]
79
+
80
+ secret = os.urandom(32)
81
+ await cur.execute(
82
+ """
83
+ INSERT INTO APP_CONFIG (k, v)
84
+ VALUES (%s, %s)
85
+ ON DUPLICATE KEY UPDATE
86
+ v = VALUES(v)
87
+ """,
88
+ ("link.secret", secret)
89
+ )
90
+
91
+ await conn.commit()
92
+ return secret
93
+
94
+ async def get_config_value(self, key: str) -> Optional[SupportedType]:
95
+ async with self._pool.acquire() as conn:
96
+ async with conn.cursor() as cur:
97
+ await cur.execute(
98
+ "SELECT v, type FROM APP_CONFIG WHERE k = %s",
99
+ (key,)
100
+ )
101
+ row = await cur.fetchone()
102
+ if not row:
103
+ return None
104
+
105
+ data, vtype = row
106
+ return decode_value(data, vtype)
107
+
108
+ async def set_config_value(self, key: str, value: SupportedType) -> None:
109
+ data, vtype = encode_value(value)
110
+
111
+ async with self._pool.acquire() as conn:
112
+ async with conn.cursor() as cur:
113
+ try:
114
+ await cur.execute(
115
+ """
116
+ INSERT INTO APP_CONFIG (k, v, type)
117
+ VALUES (%s, %s, %s)
118
+ ON DUPLICATE KEY UPDATE
119
+ v = VALUES(v),
120
+ type = VALUES(type)
121
+ """,
122
+ (key, data, vtype)
123
+ )
124
+ await conn.commit()
125
+ except Exception:
126
+ await conn.rollback()
127
+ raise
tgfs/info.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __version__="0.0.1"
2
+ class Version:
3
+ version = __version__.split(".", maxsplit=3)
4
+ major = int(version[0])
5
+ minor = int(version[1])
6
+ patch = int(version[2])
7
+
8
+ def __str__(self) -> str:
9
+ return f"{self.major}.{self.minor}.{self.patch}"
tgfs/log.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import logging
18
+ from tgfs.config import Config
19
+
20
+ LOG_LEVEL = logging.DEBUG if Config.DEBUG else logging.INFO
21
+ EXT_LOG_LEVEL = logging.INFO if Config.EXT_DEBUG else logging.ERROR
22
+ logging.basicConfig(level=LOG_LEVEL)
23
+ logging.getLogger("telethon").setLevel(EXT_LOG_LEVEL)
24
+ logging.getLogger("aiohttp").setLevel(EXT_LOG_LEVEL)
25
+ logging.getLogger("asyncio").setLevel(EXT_LOG_LEVEL)
26
+ logging.getLogger("pymongo").setLevel(EXT_LOG_LEVEL)
27
+ logging.getLogger("aiomysql").setLevel(EXT_LOG_LEVEL)
28
+ log = logging.getLogger("tgstream")
tgfs/paralleltransfer.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2019 Tulir Asokan
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ # Modifications made by Deekshith SH, 2025-2026
18
+ # Copyright (C) 2025-2026 Deekshith SH
19
+
20
+ # pylint: disable=protected-access
21
+
22
+ import copy
23
+ import logging
24
+ import asyncio
25
+ import math
26
+ from collections import defaultdict
27
+ from contextlib import asynccontextmanager
28
+ from dataclasses import dataclass
29
+ from typing import AsyncGenerator, Optional
30
+
31
+ from telethon import TelegramClient
32
+ from telethon.crypto import AuthKey
33
+ from telethon.network import MTProtoSender
34
+ from telethon.tl.alltlobjects import LAYER
35
+ from telethon.tl.functions import InvokeWithLayerRequest
36
+ from telethon.tl.functions.auth import ExportAuthorizationRequest, ImportAuthorizationRequest
37
+ from telethon.tl.functions.upload import GetFileRequest
38
+ from telethon.tl.types import DcOption
39
+ from telethon.errors import DcIdInvalidError
40
+
41
+ from tgfs.config import Config
42
+ from tgfs.utils.types import InputTypeLocation
43
+
44
+ root_log = logging.getLogger(__name__)
45
+
46
+ if Config.CONNECTION_LIMIT > 25:
47
+ root_log.warning("The connection limit should not be set above 25 to avoid"
48
+ " infinite disconnect/reconnect loops")
49
+
50
+
51
+ @dataclass
52
+ class Connection:
53
+ log: logging.Logger
54
+ sender: MTProtoSender
55
+ lock: asyncio.Lock
56
+ users: int = 0
57
+
58
+
59
+ class DCConnectionManager:
60
+ log: logging.Logger
61
+ client: TelegramClient
62
+
63
+ dc_id: int
64
+ dc: Optional[DcOption]
65
+ auth_key: Optional[AuthKey]
66
+ connections: list[Connection]
67
+
68
+ _list_lock: asyncio.Lock
69
+
70
+ def __init__(self, client: TelegramClient, dc_id: int, parent_log: logging.Logger) -> None:
71
+ self.log = parent_log.getChild(f"dc{dc_id}")
72
+ self.client = client
73
+ self.dc_id = dc_id
74
+ self.auth_key = None
75
+ self.connections = []
76
+ self._list_lock = asyncio.Lock()
77
+ self.dc = None
78
+
79
+ async def _new_connection(self) -> Connection:
80
+ if not self.dc:
81
+ self.dc = await self.client._get_dc(self.dc_id)
82
+ sender = MTProtoSender(self.auth_key, loggers=self.client._log)
83
+ index = len(self.connections) + 1
84
+ conn = Connection(sender=sender, log=self.log.getChild(
85
+ f"conn{index}"), lock=asyncio.Lock())
86
+ self.connections.append(conn)
87
+ try:
88
+ async with conn.lock:
89
+ conn.log.info("Connecting...")
90
+ connection_info = self.client._connection(self.dc.ip_address, self.dc.port, self.dc.id,
91
+ loggers=self.client._log,
92
+ proxy=self.client._proxy)
93
+ await sender.connect(connection_info)
94
+ if not self.auth_key:
95
+ await self._export_auth_key(conn)
96
+ return conn
97
+ except Exception as e:
98
+ self.connections.remove(conn)
99
+ raise e
100
+
101
+ async def _export_auth_key(self, conn: Connection) -> None:
102
+ self.log.info("Exporting auth to DC %s"
103
+ " (main client is in %s)", self.dc.id, self.client.session.dc_id)
104
+ try:
105
+ auth = await self.client(ExportAuthorizationRequest(self.dc.id))
106
+ except DcIdInvalidError:
107
+ self.log.debug("Got DcIdInvalidError")
108
+ self.auth_key = self.client.session.auth_key
109
+ conn.sender.auth_key = self.auth_key
110
+ return
111
+ init_request = copy.copy(self.client._init_request)
112
+ init_request.query = ImportAuthorizationRequest(
113
+ id=auth.id, bytes=auth.bytes
114
+ )
115
+ req = InvokeWithLayerRequest(
116
+ LAYER, init_request
117
+ )
118
+ await conn.sender.send(req)
119
+ self.auth_key = conn.sender.auth_key
120
+
121
+ async def _next_connection(self) -> Connection:
122
+ best_conn: Optional[Connection] = None
123
+ for conn in self.connections:
124
+ if not best_conn or conn.users < best_conn.users:
125
+ best_conn = conn
126
+ if (not best_conn or best_conn.users > 0) and len(self.connections) < Config.CONNECTION_LIMIT:
127
+ best_conn = await self._new_connection()
128
+ return best_conn
129
+
130
+ @asynccontextmanager
131
+ async def get_connection(self) -> AsyncGenerator[Connection, None]:
132
+ async with self._list_lock:
133
+ conn: Connection = await asyncio.shield(self._next_connection())
134
+ # The connection is locked so reconnections don't stack
135
+ async with conn.lock:
136
+ conn.users += 1
137
+ try:
138
+ yield conn
139
+ finally:
140
+ conn.users -= 1
141
+
142
+ async def disconnect(self) -> None:
143
+ async with self._list_lock:
144
+ await asyncio.gather(*[conn.sender.disconnect() for conn in self.connections])
145
+ self.connections.clear()
146
+
147
+
148
+ class ParallelTransferrer:
149
+ log: logging.Logger
150
+ client: TelegramClient
151
+ client_id: int
152
+ dc_managers: dict[int, DCConnectionManager]
153
+ users: int
154
+
155
+ def __init__(self, client: TelegramClient, client_id: int) -> None:
156
+ self.log = root_log.getChild(f"bot{client_id}")
157
+ self.client = client
158
+ self.client_id = client_id
159
+ self.users = 0
160
+
161
+ self.dc_managers = defaultdict(lambda: None)
162
+
163
+ def _get_dc_manager(self, dc_id: int) -> DCConnectionManager:
164
+ if self.dc_managers[dc_id] is None:
165
+ self.dc_managers[dc_id] = DCConnectionManager(self.client, dc_id, self.log)
166
+ return self.dc_managers[dc_id]
167
+
168
+ def post_init(self) -> None:
169
+ # Pre-initialize the manager for the current session's DC
170
+ manager = self._get_dc_manager(self.client.session.dc_id)
171
+ manager.auth_key = self.client.session.auth_key
172
+
173
+ async def close_connection(self) -> None:
174
+ tasks = []
175
+ for dcid, dcm in self.dc_managers.items():
176
+ if dcm and dcm.connections:
177
+ self.log.debug("Closing connections for DC %d", dcid)
178
+ tasks.append(dcm.disconnect())
179
+ if tasks:
180
+ await asyncio.gather(*tasks)
181
+ self.log.debug("All DC connections closed")
182
+
183
+ async def _int_download(self, request: GetFileRequest, first_part: int, last_part: int,
184
+ part_count: int, part_size: int, dc_id: int, first_part_cut: int,
185
+ last_part_cut: int) -> AsyncGenerator[bytes, None]:
186
+ log = self.log
187
+ self.users += 1
188
+ try:
189
+ part = first_part
190
+ dcm = self._get_dc_manager(dc_id)
191
+ async with dcm.get_connection() as conn:
192
+ log = conn.log
193
+ while part <= last_part:
194
+ result = await self.client._call(conn.sender, request)
195
+
196
+ if not result.bytes:
197
+ break
198
+
199
+ request.offset += part_size
200
+ if last_part == first_part:
201
+ yield result.bytes[first_part_cut:last_part_cut]
202
+ elif part == first_part:
203
+ yield result.bytes[first_part_cut:]
204
+ elif part == last_part:
205
+ yield result.bytes[:last_part_cut]
206
+ else:
207
+ yield result.bytes
208
+ log.debug("Part %d/%d (total %d) downloaded", part, last_part, part_count)
209
+ part += 1
210
+ log.info("Parallel download finished")
211
+ except (GeneratorExit, StopAsyncIteration, asyncio.CancelledError):
212
+ log.info("Parallel download interrupted")
213
+ raise
214
+ except Exception: # pylint: disable=W0718
215
+ log.error("Parallel download errored", exc_info=True)
216
+ finally:
217
+ self.users -= 1
218
+
219
+ def download(self, location: InputTypeLocation, dc_id: int, file_size: int, offset: int, limit: int
220
+ ) -> AsyncGenerator[bytes, None]:
221
+ part_size = Config.DOWNLOAD_PART_SIZE
222
+ first_part_cut = offset % part_size
223
+ first_part = math.floor(offset / part_size)
224
+ last_part_cut = (limit % part_size) + 1
225
+ last_part = math.ceil(limit / part_size)
226
+ part_count = math.ceil(file_size / part_size)
227
+ self.log.info("Starting parallel download: chunks %d-%d of %d %s",
228
+ first_part, last_part, part_count, location)
229
+ request = GetFileRequest(location, offset=first_part * part_size, limit=part_size)
230
+
231
+ return self._int_download(
232
+ request, first_part, last_part, part_count, part_size, dc_id,
233
+ first_part_cut, last_part_cut
234
+ )
tgfs/patches/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Monkey patches and optional runtime overrides.
2
+
3
+ # Importing this package applies all enabled patches.
4
+ # Currently empty.
tgfs/plugins/admin.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import datetime
18
+ import logging
19
+ from typing import Optional
20
+
21
+ from telethon import events
22
+
23
+ from tgfs.config import Config
24
+ from tgfs.telegram import client
25
+ from tgfs.database import DB
26
+ from tgfs.utils.utils import parse_token
27
+
28
+
29
+ def is_admin(user_id: int):
30
+ return user_id in Config.ADMIN_IDS
31
+
32
+
33
+ def parse_userid(token: str) -> Optional[int]:
34
+ if token.isdigit():
35
+ return int(token)
36
+ else:
37
+ data = parse_token(token)
38
+ if data:
39
+ return data[0]
40
+
41
+
42
+ log = logging.getLogger(__name__)
43
+
44
+
45
+ @client.on(events.NewMessage(
46
+ incoming=True,
47
+ pattern=r"^/help",
48
+ func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
49
+ ))
50
+ async def handle_help_command(evt: events.NewMessage.Event) -> None:
51
+ await evt.reply(f"""
52
+ Admin Commands:
53
+ /ban <userid> <reason>: ban a user
54
+ /warn <userid> <reason>: warn a user; after {Config.MAX_WARNS} warnings, the user will be banned
55
+ /unban <userid>: unban a user
56
+ /clearwarns <userid>: clear all warnings for a user
57
+
58
+ /listfiles <fileid | token>: list users who generated a link for a specific file
59
+ /restrictfile <fileid | token>: restrict a file link to prevent users from downloading the file
60
+ /deletefile <userid> <fileid>: delete a file associated with a user
61
+ /deletefile <token>: delete a file using its token
62
+ /parsetoken <token>: parse a token to retrieve the file ID and user ID
63
+
64
+ download url strcture: "http://<PUBLIC_URL>/dl/<token>/<signature>"
65
+ """)
66
+
67
+
68
+ @client.on(events.NewMessage(
69
+ incoming=True,
70
+ pattern=r"^/ban (\d+) (.+)$",
71
+ func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
72
+ ))
73
+ async def handle_ban_command(evt: events.NewMessage.Event) -> None:
74
+ user_id = int(evt.pattern_match.group(1))
75
+ reason = str(evt.pattern_match.group(2))
76
+ user = await DB.db.get_user(user_id)
77
+ if not user:
78
+ return await evt.reply("User doesn't exist in Database")
79
+ user.ban_date = datetime.datetime.now(datetime.UTC)
80
+ if await DB.db.upsert_user(user):
81
+ await evt.reply(f"Banned user {user_id}")
82
+ await client.send_message(user_id, f"Your banned\nReason: {reason}")
83
+ else:
84
+ await evt.reply(f"Unable to ban user {user_id}")
85
+
86
+
87
+ @client.on(events.NewMessage(
88
+ incoming=True,
89
+ pattern=r"^/warn (\d+) (.+)$",
90
+ func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
91
+ ))
92
+ async def handle_warn_command(evt: events.NewMessage.Event) -> None:
93
+ user_id = int(evt.pattern_match.group(1))
94
+ reason = str(evt.pattern_match.group(2))
95
+ user = await DB.db.get_user(user_id)
96
+ if not user:
97
+ return await evt.reply("User doesn't exist in Database")
98
+ user.warns += 1
99
+ if user.warns >= Config.MAX_WARNS:
100
+ await evt.reply("User reached max warns")
101
+ user.ban_date = datetime.datetime.now(datetime.UTC)
102
+ if await DB.db.upsert_user(user):
103
+ await evt.reply(f"User has {user.warns}/{Config.MAX_WARNS} Warns")
104
+ await client.send_message(user_id, f"You have {user.warns}/{Config.MAX_WARNS} Warns\nReason: {reason}")
105
+ else:
106
+ await evt.reply(f"Unable to warn user {user_id}")
107
+
108
+
109
+ @client.on(events.NewMessage(
110
+ incoming=True,
111
+ pattern=r"^/unban (\d+)$",
112
+ func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
113
+ ))
114
+ async def handle_unban_command(evt: events.NewMessage.Event) -> None:
115
+ user_id = int(evt.pattern_match.group(1))
116
+ user = await DB.db.get_user(user_id)
117
+ if not user:
118
+ return await evt.reply("User doesn't exist in Database")
119
+ user.ban_date = None
120
+ user.warns = 0
121
+ if await DB.db.upsert_user(user):
122
+ await evt.reply("User has Unbanned and Warns reset to 0")
123
+ await client.send_message(user_id, "You are unbanned now you can use this bot")
124
+ else:
125
+ await evt.reply(f"Unable to unban user {user_id}")
126
+
127
+
128
+ @client.on(events.NewMessage(
129
+ incoming=True,
130
+ pattern=r"^/clearwarns (\d+)$",
131
+ func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
132
+ ))
133
+ async def handle_clearwarns_command(evt: events.NewMessage.Event) -> None:
134
+ user_id = int(evt.pattern_match.group(1))
135
+ user = await DB.db.get_user(user_id)
136
+ if not user:
137
+ return await evt.reply("User doesn't exist in Database")
138
+ user.warns = 0
139
+ if await DB.db.upsert_user(user):
140
+ await evt.reply("Warns reset to 0")
141
+ await client.send_message(user_id, "Your warns reset to 0")
142
+ else:
143
+ await evt.reply(f"Unable to clear warns for user {user_id}")
144
+
145
+
146
+ @client.on(events.NewMessage(
147
+ incoming=True,
148
+ pattern=r"^/listfiles (\d+|[A-Za-z0-9_\-:/]+)$",
149
+ func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
150
+ ))
151
+ async def handle_listfiles_command(evt: events.NewMessage.Event) -> None:
152
+ file_id = evt.pattern_match.group(1)
153
+ if file_id.isdigit():
154
+ file_id = int(file_id)
155
+ else:
156
+ data = parse_token(file_id)
157
+ if not data:
158
+ return await evt.reply("Invalid File Id")
159
+ file_id = data[1]
160
+ users = await DB.db.get_file_users(file_id)
161
+ if not users:
162
+ return await evt.reply("No users have generated a link for this file.")
163
+ reply_text = (
164
+ "These users generated a link for this file:\n"
165
+ + "\n".join(f"[{uid}](tg://user?id={uid})" for uid in users)
166
+ )
167
+ await evt.reply(reply_text)
168
+
169
+
170
+ @client.on(events.NewMessage(
171
+ incoming=True,
172
+ pattern=r"^/restrictfile (\d+|[A-Za-z0-9_\-:/]+)$",
173
+ func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
174
+ ))
175
+ async def handle_restrictfile_command(evt: events.NewMessage.Event) -> None:
176
+ file_id = evt.pattern_match.group(1)
177
+ if file_id.isdigit():
178
+ file_id = int(file_id)
179
+ else:
180
+ data = parse_token(file_id)
181
+ if not data:
182
+ return await evt.reply("Invalid File Id")
183
+ file_id = data[1]
184
+ file = await DB.db.get_file(file_id)
185
+ if not file:
186
+ return await evt.reply("File not found in Database")
187
+ file.is_deleted = True
188
+ await DB.db.update_file_restriction(file.id, file.is_deleted)
189
+ await evt.reply(f"Restricted File with File Id {file.id}")
190
+
191
+
192
+ @client.on(events.NewMessage(
193
+ incoming=True,
194
+ pattern=r"^/deletefile (?:(\d+) (\d+)|([A-Za-z0-9_\-:/]+))$",
195
+ func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
196
+ ))
197
+ async def handle_deletefile_command(evt: events.NewMessage.Event) -> None:
198
+ user_id = evt.pattern_match.group(1)
199
+ file_id = evt.pattern_match.group(2)
200
+ token = evt.pattern_match.group(3)
201
+
202
+ if token:
203
+ data = parse_token(token)
204
+ if not data:
205
+ return
206
+ user_id, file_id = data
207
+ else:
208
+ user_id = int(user_id)
209
+ file_id = int(file_id)
210
+ if await DB.db.remove_file(file_id, user_id):
211
+ await evt.reply(f"Deleted file {file_id} from user [{user_id}](tg://user?id={user_id})")
212
+ else:
213
+ await evt.reply(f"Unable to delete file {file_id} associated with user [{user_id}](tg://user?id={user_id})")
214
+
215
+
216
+ @client.on(events.NewMessage(
217
+ incoming=True,
218
+ pattern=r"^/parsetoken ([A-Za-z0-9_\-:/]+)$",
219
+ func=lambda x: x.is_private and not x.file and is_admin(x.sender_id)
220
+ ))
221
+ async def handle_parsetoken_command(evt: events.NewMessage.Event) -> None:
222
+ token = evt.pattern_match.group(1)
223
+ data = parse_token(token)
224
+ if not data:
225
+ return await evt.reply("Invalid Token")
226
+ user_id, file_id = data
227
+ await evt.reply(f"Token {token} is associated with User ID {user_id} and File ID {file_id}")
tgfs/plugins/callback.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import logging
18
+
19
+ from telethon import Button, events
20
+ from telethon.tl.types import InputMediaDocument, InputMediaPhoto
21
+
22
+ from tgfs.config import Config
23
+ from tgfs.telegram import client
24
+ from tgfs.database import DB
25
+ from tgfs.utils.translation import get_lang
26
+ from tgfs.utils.utils import check_get_user, human_bytes, make_token
27
+
28
+ log = logging.getLogger(__name__)
29
+
30
+
31
+ @client.on(events.CallbackQuery(pattern=r"^tos_agree_[1-9]\d{0,19}$"))
32
+ async def handle_tos_button(evt: events.CallbackQuery.Event):
33
+ lang = get_lang()
34
+ callback_data = evt.data.decode('utf-8')
35
+ log.debug("Callback data: %s", callback_data)
36
+ user_id = evt.sender_id
37
+ if not await DB.db.add_user(user_id):
38
+ await evt.answer(lang.SOMETHING_WENT_WRONG)
39
+ return
40
+ # msg = await client.get_messages(user_id, ids=msg_id)
41
+ # await handle_file_message(evt, msg)
42
+ await evt.answer(lang.ACCEPTED_TOS_TEXT)
43
+ await evt.edit(buttons=[[Button.inline(lang.AGREED, b"tos_agreed")]])
44
+
45
+
46
+ @client.on(events.CallbackQuery(pattern=r"^(fileinfo|groupinfo)_page_(\d+)$"))
47
+ async def handle_list_page(evt: events.CallbackQuery.Event) -> None:
48
+ user = await check_get_user(evt.sender_id, evt.message_id)
49
+ lang = get_lang(user)
50
+ kind = evt.pattern_match.group(1).decode()
51
+ page_no = int(evt.pattern_match.group(2))
52
+ is_group = kind == "groupinfo"
53
+ user_id = evt.sender_id
54
+
55
+ total_items = (await DB.db.total_groups(user_id)
56
+ if is_group else await DB.db.total_files(user_id))
57
+ if total_items == 0:
58
+ await evt.edit(lang.NO_LABEL_LINKS_TEXT.format(label=lang.GROUP if is_group else lang.FILE))
59
+ return
60
+
61
+ limit = Config.FILE_INDEX_LIMIT
62
+ total_pages = (total_items + limit - 1) // limit
63
+
64
+ if page_no < 0 or page_no >= total_pages:
65
+ await evt.answer(lang.INVALID_PAGE, alert=True)
66
+ return
67
+
68
+ offset = page_no * limit
69
+ items_gen = (
70
+ DB.db.get_groups(user_id, offset, limit)
71
+ if is_group
72
+ else DB.db.get_files(user_id, offset, limit)
73
+ )
74
+
75
+ buttons: list[list[Button]] = []
76
+
77
+ async for item_id, name in items_gen:
78
+ buttons.append([
79
+ Button.inline(name, data=f"{kind}_file_{item_id}_{page_no}")
80
+ ])
81
+
82
+ if not buttons:
83
+ await evt.edit(lang.NO_LABELS_TEXT.format(label=lang.GROUPS if is_group else lang.FILES))
84
+ return
85
+
86
+ nav = []
87
+ if page_no > 0:
88
+ nav.append(Button.inline("<<", f"{kind}_page_{page_no - 1}"))
89
+
90
+ nav.append(Button.inline(
91
+ lang.FILES_BUTTON_CURRENT.format(
92
+ page_no=page_no + 1, total_pages=total_pages),
93
+ b"noop"
94
+ ))
95
+
96
+ if page_no + 1 < total_pages:
97
+ nav.append(Button.inline(">>", f"{kind}_page_{page_no + 1}"))
98
+
99
+ buttons.append(nav)
100
+ buttons.append([Button.inline(lang.BACK_TEXT, b"files_menu")])
101
+
102
+ await evt.edit(
103
+ lang.TOTAL_LABEL_COUNT.format(
104
+ total=total_items, label=lang.GROUP if is_group else lang.FILE),
105
+ buttons=buttons
106
+ )
107
+
108
+
109
+ @client.on(events.CallbackQuery(pattern=r"^fileinfo_file_(\d+)_(\d+)(?:_(\d+))?$"))
110
+ async def handle_fileinfo_button(evt: events.CallbackQuery.Event):
111
+ user = await check_get_user(evt.sender_id, evt.message_id)
112
+ lang = get_lang(user)
113
+ file_id = int(evt.pattern_match.group(1))
114
+ page_no = int(evt.pattern_match.group(2))
115
+ group_id = int(evt.pattern_match.group(
116
+ 3)) if evt.pattern_match.group(3) else None
117
+ user_id = evt.sender_id
118
+ file_info = await DB.db.get_file(file_id, user_id)
119
+ if file_info is None:
120
+ await evt.answer(lang.FILE_NOT_FOUND_TEXT, alert=True)
121
+ return
122
+ token = make_token(user_id, file_info.id)
123
+ url = f"{Config.PUBLIC_URL}/dl/{token}"
124
+ wt_url = f"{Config.PUBLIC_URL}/wt/{token}"
125
+ await evt.edit(
126
+ lang.FILE_INFO_TEXT.format(
127
+ file_id=file_info.id,
128
+ dc_id=file_info.dc_id,
129
+ file_size=human_bytes(file_info.file_size),
130
+ mime_type=file_info.mime_type,
131
+ file_name=file_info.file_name,
132
+ file_type=lang.PHOTO if file_info.thumb_size else lang.DOCUMENT,
133
+ restricted=lang.YES if file_info.is_deleted else lang.NO
134
+ ),
135
+ buttons=[
136
+ [
137
+ Button.url(lang.DOWNLOAD, url),
138
+ Button.url(lang.WATCH, wt_url),
139
+ ],
140
+ [
141
+ Button.inline(
142
+ lang.DELETE, (
143
+ f"fileinfo_delconf2_{file_info.id}_{page_no}"
144
+ f"{f'_{group_id}' if group_id else ''}"
145
+ )),
146
+ Button.inline(lang.GET_FILE_TEXT,
147
+ f"fileinfo_get_{file_info.id}")
148
+ ],
149
+ [Button.inline(
150
+ lang.BACK_TEXT, f"groupinfo_file_{group_id}_{page_no}" if group_id else f"fileinfo_page_{page_no}")]
151
+ ]
152
+ )
153
+
154
+
155
+ @client.on(events.CallbackQuery(pattern=r"^groupinfo_file_(\d+)_(\d+)$"))
156
+ async def handle_groupinfo_button(evt: events.CallbackQuery.Event):
157
+ user = await check_get_user(evt.sender_id, evt.message_id)
158
+ lang = get_lang(user)
159
+ group_id = int(evt.pattern_match.group(1))
160
+ page_no = int(evt.pattern_match.group(2))
161
+ user_id = evt.sender_id
162
+ file_info = await DB.db.get_group(group_id, user_id)
163
+ if file_info is None:
164
+ await evt.answer(lang.GROUP_NOT_FOUND_TEXT, alert=True)
165
+ return
166
+ token = make_token(user_id, file_info.group_id)
167
+ buttons: list[list[Button]] = [
168
+ [Button.url(lang.EXTERNAL_LINK, f"{Config.PUBLIC_URL}/group/{token}")]
169
+ ]
170
+ if file_info.files and len(file_info.files) <= 98:
171
+ async for file_id, name in DB.db.get_files2(user_id, file_info.files):
172
+ buttons.append(
173
+ [Button.inline(name, f"fileinfo_file_{file_id}_{page_no}_{group_id}")])
174
+ buttons.append(
175
+ [
176
+ Button.inline(lang.BACK_TEXT, f"groupinfo_page_{page_no}"),
177
+ Button.inline(
178
+ lang.DELETE, f"groupinfo_delconf2_{file_info.group_id}_{page_no}")
179
+ ]
180
+ )
181
+
182
+ await evt.edit(
183
+ lang.GROUP_INFO_TEXT.format(
184
+ name=file_info.name,
185
+ created_at=file_info.created_at,
186
+ total_files=len(file_info.files) if file_info.files else 0
187
+ ),
188
+ buttons=buttons
189
+ )
190
+
191
+
192
+ @client.on(events.CallbackQuery(pattern=r"^fileinfo_get_(\d+)$"))
193
+ async def handle_fileinfo_get_button(evt: events.CallbackQuery.Event):
194
+ user = await check_get_user(evt.sender_id, evt.message_id)
195
+ lang = get_lang(user)
196
+ file_id = int(evt.pattern_match.group(1))
197
+ user_id = evt.sender_id
198
+ file_info = await DB.db.get_file(file_id, user_id)
199
+ if file_info is None:
200
+ await evt.answer(lang.FILE_NOT_FOUND_TEXT, alert=True)
201
+ return
202
+ location = await DB.db.get_location(file_info, Config.BOT_ID)
203
+ input_media = InputMediaPhoto(
204
+ location) if location.thumb_size else InputMediaDocument(location)
205
+ await client.send_file(user_id, input_media, caption=file_info.file_name)
206
+
207
+
208
+ @client.on(events.CallbackQuery(pattern=r"^(fileinfo|groupinfo)_delconf2_(\d+)_(\d+)(?:_(\d+))?$"))
209
+ async def handle_fileinfo_del_conf_button(evt: events.CallbackQuery.Event):
210
+ user = await check_get_user(evt.sender_id, evt.message_id)
211
+ lang = get_lang(user)
212
+ kind = evt.pattern_match.group(1).decode()
213
+ file_id = int(evt.pattern_match.group(2))
214
+ page_no = int(evt.pattern_match.group(3))
215
+ group_id = int(evt.pattern_match.group(
216
+ 4)) if evt.pattern_match.group(4) else None
217
+ user_id = evt.sender_id
218
+ is_group = kind == "groupinfo"
219
+
220
+ file_info = await DB.db.get_group(file_id, user_id) if is_group else await DB.db.get_file(file_id, user_id)
221
+ if file_info is None:
222
+ await evt.answer(lang.GROUP_NOT_FOUND_TEXT if is_group else lang.FILE_NOT_FOUND_TEXT, alert=True)
223
+ return
224
+ await evt.edit(
225
+ lang.CONFIRM_DELETE_TEXT.format(
226
+ label=lang.GROUP if is_group else lang.FILE),
227
+ buttons=[
228
+ [Button.inline(lang.YES+' '+lang.DELETE, (
229
+ f"{kind}_delete_{file_id}_{page_no}"
230
+ f"{f'_{group_id}' if group_id else ''}"
231
+ ))],
232
+ [Button.inline(lang.NO, (
233
+ f"{kind}_file_{file_id}_{page_no}"
234
+ f"{f'_{group_id}' if group_id else ''}"
235
+ ))]
236
+ ]
237
+ )
238
+
239
+
240
+ @client.on(events.CallbackQuery(pattern=r"^(fileinfo|groupinfo)_delete_(\d+)_(\d+)(?:_(\d+))?$"))
241
+ async def handle_fileinfo_del_button(evt: events.CallbackQuery.Event):
242
+ user = await check_get_user(evt.sender_id, evt.message_id)
243
+ lang = get_lang(user)
244
+ kind = evt.pattern_match.group(1).decode()
245
+ file_id = int(evt.pattern_match.group(2))
246
+ page_no = int(evt.pattern_match.group(3))
247
+ group_id = int(evt.pattern_match.group(
248
+ 4)) if evt.pattern_match.group(4) else None
249
+ user_id = evt.sender_id
250
+ is_group = kind == "groupinfo"
251
+
252
+ file_info = await DB.db.get_group(file_id, user_id) if is_group else await DB.db.get_file(file_id, user_id)
253
+ if file_info is None:
254
+ await evt.answer(lang.GROUP_NOT_FOUND_TEXT if is_group else lang.FILE_NOT_FOUND_TEXT, alert=True)
255
+ return
256
+ if is_group:
257
+ await DB.db.delete_group(file_id, user_id)
258
+ else:
259
+ await DB.db.remove_file(file_id, user_id)
260
+ await evt.edit(lang.DELETED_SUCCESSFULLY_TEXT.format(label=lang.GROUP if is_group else lang.FILE), buttons=[
261
+ [
262
+ Button.inline(lang.BACK_TEXT, (
263
+ f"groupinfo_file_{group_id}_{page_no}" if group_id else f"{kind}_page_{page_no}"
264
+ ))
265
+ ]
266
+ ])
267
+
268
+
269
+ @client.on(events.CallbackQuery(pattern=r"^files_menu$"))
270
+ async def handle_files_menu_button(evt: events.CallbackQuery.Event):
271
+ user = await check_get_user(evt.sender_id, evt.message_id)
272
+ lang = get_lang(user)
273
+ await evt.edit(
274
+ lang.SELECT_TYPE_OF_FILE,
275
+ buttons=[
276
+ [Button.inline(lang.FILES, "fileinfo_page_0")],
277
+ [Button.inline(lang.GROUPS, "groupinfo_page_0")]
278
+ ]
279
+ )
tgfs/plugins/custom.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import re
18
+ import logging
19
+
20
+ from telethon import events
21
+ from telethon.tl.types import InputMediaDocument, InputMediaPhoto
22
+
23
+ from tgfs.config import Config
24
+ from tgfs.telegram import client
25
+ from tgfs.database import DB
26
+ from tgfs.utils.translation import get_lang
27
+ from tgfs.utils.types import User
28
+ from tgfs.utils.utils import parse_token
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+
33
+ async def send_file(_: events.NewMessage.Event, user_id: int, file_id: int) -> None:
34
+ lang = get_lang(user_id)
35
+ file_info = await DB.db.get_file(file_id, user_id)
36
+ if file_info is None:
37
+ return lang.FILE_ID_NOT_FOUND.format(file_id=file_id)
38
+ location = await DB.db.get_location(file_info, Config.BOT_ID)
39
+ input_media = InputMediaPhoto(
40
+ location) if location.thumb_size else InputMediaDocument(location)
41
+ await client.send_file(user_id, input_media, caption=file_info.file_name)
42
+
43
+
44
+ async def handle_file_url(evt: events.NewMessage.Event, user: User, match: re.Match) -> None:
45
+ lang = get_lang(user)
46
+ payload = match.group("payload")
47
+ sig = match.group("sig")
48
+ pt = parse_token(payload, sig)
49
+ if not pt:
50
+ await evt.reply(lang.INAVLID_LINK_TEXT)
51
+ return
52
+ user_id, file_id = pt
53
+ status = await send_file(evt, user_id, file_id)
54
+ if status:
55
+ await evt.reply(status)
56
+
57
+
58
+ async def handle_group_url(evt: events.NewMessage.Event, user: User, match: re.Match) -> None:
59
+ lang = get_lang(user)
60
+ payload = match.group("payload")
61
+ sig = match.group("sig")
62
+ pt = parse_token(payload, sig)
63
+ if not pt:
64
+ await evt.reply(lang.INAVLID_LINK_TEXT)
65
+ return
66
+ user_id, group_id = pt
67
+ group = await DB.db.get_group(group_id, user_id)
68
+ for file_id in group.files:
69
+ status = await send_file(evt, user_id, file_id)
70
+ if status:
71
+ await evt.reply(status)
72
+ await evt.reply(lang.GROUP_ENDOF_FILES)
73
+
74
+ # pylint: disable=C0301
75
+ HANDLERS = [
76
+ (re.compile(
77
+ r"^(?P<scheme>https?):\/\/(?P<host>(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::(?P<port>\d{1,5}))?\/dl\/(?P<payload>[^\/]+)\/(?P<sig>[^\/]+)$"), handle_file_url),
78
+ (re.compile(
79
+ r"^(?P<scheme>https?):\/\/(?P<host>(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}|\d{1,3}(?:\.\d{1,3}){3})(?::(?P<port>\d{1,5}))?\/group\/(?P<payload>[^\/]+)\/(?P<sig>[^\/]+)$"), handle_group_url)
80
+ ]
tgfs/plugins/files.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import logging
18
+ from typing import cast
19
+
20
+ from telethon import events, Button
21
+ from telethon.custom import Message
22
+ from telethon.utils import get_input_location
23
+ from telethon.errors import ButtonUrlInvalidError
24
+
25
+ from tgfs.config import Config
26
+ from tgfs.database import DB
27
+ from tgfs.telegram import client, multi_clients
28
+ from tgfs.utils.translation import get_lang
29
+ from tgfs.utils.utils import check_get_user, make_token
30
+ from tgfs.utils.types import FileInfo, FileSource, InputTypeLocation, Status
31
+
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ @client.on(events.NewMessage(incoming=True, func=lambda x: x.is_private and x.file))
37
+ async def handle_file_message(evt: events.NewMessage.Event, msg=None) -> None:
38
+ msg: Message = evt.message if not msg else msg
39
+ user = await check_get_user(msg.sender_id, msg.id)
40
+ if user is None:
41
+ return
42
+ if user.curt_op in [Status.GROUP_NAME, Status.GROUP]:
43
+ return
44
+ lang = get_lang(user)
45
+ dc_id, location = cast(
46
+ tuple[int, InputTypeLocation], get_input_location(msg.media))
47
+ file_info = FileInfo(
48
+ id=location.id,
49
+ dc_id=dc_id,
50
+ file_size=msg.file.size,
51
+ mime_type=msg.file.mime_type,
52
+ file_name=msg.file.name or f"{location.id}{msg.file.ext or ''}",
53
+ thumb_size=location.thumb_size,
54
+ is_deleted=False
55
+ )
56
+ file_source = FileSource(
57
+ chat_id=msg.chat_id,
58
+ message_id=msg.id,
59
+ )
60
+ await DB.db.add_file(user.user_id, file_info, file_source)
61
+ await DB.db.upsert_location(
62
+ multi_clients[0].client_id,
63
+ location
64
+ )
65
+ # fwd_msg: Message = await msg.forward_to(Config.BIN_CHANNEL)
66
+ token = make_token(msg.sender_id, file_info.id)
67
+ url = f"{Config.PUBLIC_URL}/dl/{token}"
68
+ wt_url = f"{Config.PUBLIC_URL}/wt/{token}"
69
+ try:
70
+ await evt.reply(
71
+ url,
72
+ buttons=[
73
+ [
74
+ Button.url(lang.DOWNLOAD, url),
75
+ Button.url(lang.WATCH, wt_url)
76
+ ],
77
+ ]
78
+ )
79
+ except ButtonUrlInvalidError as e:
80
+ log.error("Failed to send download link: %s", e)
81
+ await evt.reply(url)
82
+ log.info("Generated Link %s", url)
83
+
84
+
85
+ @client.on(events.NewMessage(incoming=True, pattern=r"^/group", func=lambda x: x.is_private and not x.file))
86
+ async def handle_group_command(evt: events.NewMessage.Event) -> None:
87
+ msg: Message = evt.message
88
+ user = await check_get_user(msg.sender_id, msg.id)
89
+ if user is None:
90
+ return
91
+ lang = get_lang(user)
92
+ if user.curt_op == Status.NO_OP:
93
+ user.curt_op = Status.GROUP
94
+ user.op_id = msg.id
95
+ await DB.db.upsert_user(user)
96
+ await evt.reply(lang.GROUP_SENDFILE_TEXT)
97
+ else:
98
+ await evt.reply(lang.ALREADY_IN_OP)
99
+
100
+
101
+ @client.on(events.NewMessage(incoming=True, pattern=r"^/done", func=lambda x: x.is_private and not x.file))
102
+ async def handle_done_command(evt: events.NewMessage.Event, user=None) -> None:
103
+ msg: Message = evt.message
104
+ user = user or await check_get_user(msg.sender_id, msg.id)
105
+ if user is None:
106
+ return
107
+ lang = get_lang(user)
108
+ if user.curt_op == Status.NO_OP:
109
+ await evt.reply(lang.NOT_IN_OP)
110
+ elif user.curt_op == Status.GROUP:
111
+ min_id = user.op_id+1
112
+ max_id = msg.id
113
+ order = 0
114
+ group_id = await DB.db.create_group(user.user_id, str(msg.id))
115
+ try:
116
+ file_msgs: list[Message] = await client.get_messages(
117
+ entity=msg.chat_id,
118
+ ids=range(min_id, max_id),
119
+ )
120
+ file_msgs = list(filter(lambda m: m and m.file, file_msgs))
121
+ if not file_msgs:
122
+ await DB.db.delete_group(group_id, user.user_id)
123
+ await evt.reply(lang.GROUP_NOFILES_TEXT)
124
+ user.curt_op = Status.NO_OP
125
+ user.op_id = 0
126
+ await DB.db.upsert_user(user)
127
+ return
128
+ for file_msg in file_msgs:
129
+ dc_id, location = cast(
130
+ tuple[int, InputTypeLocation], get_input_location(file_msg.media))
131
+ file_info = FileInfo(
132
+ id=location.id,
133
+ dc_id=dc_id,
134
+ file_size=file_msg.file.size,
135
+ mime_type=file_msg.file.mime_type,
136
+ file_name=file_msg.file.name or f"{location.id}{file_msg.file.ext or ''}",
137
+ thumb_size=location.thumb_size,
138
+ is_deleted=False
139
+ )
140
+ file_source = FileSource(
141
+ chat_id=file_msg.chat_id,
142
+ message_id=file_msg.id
143
+ )
144
+ await DB.db.add_file(user.user_id, file_info, file_source)
145
+ await DB.db.upsert_location(
146
+ multi_clients[0].client_id,
147
+ location
148
+ )
149
+ order += 1
150
+ await DB.db.add_file_to_group(group_id, user.user_id, file_info.id, order)
151
+ await evt.reply(lang.GROUP_NAME_TEXT)
152
+ user.curt_op = Status.GROUP_NAME
153
+ user.op_id = group_id
154
+ await DB.db.upsert_user(user)
155
+ except Exception as e: # pylint: disable=W0718
156
+ DB.db.delete_group(group_id, user.user_id)
157
+ log.error(e, exc_info=True, stack_info=True)
158
+ else:
159
+ await evt.reply(lang.UNKNOWN_COMMAND)
160
+
161
+ @client.on(events.NewMessage(incoming=True, pattern=r"^/files", func=lambda x: x.is_private and not x.file))
162
+ async def handle_myfiles_command(evt: events.NewMessage.Event) -> None:
163
+ msg: Message = evt.message
164
+ user = await check_get_user(msg.sender_id, msg.id)
165
+ if user is None:
166
+ return
167
+ lang = get_lang(user)
168
+ total_files = await DB.db.total_files(user.user_id)
169
+ total_groups = await DB.db.total_groups(user.user_id)
170
+ await evt.reply(lang.FILES_TEXT.format(total_files=total_files, total_groups=total_groups),
171
+ buttons=[
172
+ [Button.inline(lang.FILES, "fileinfo_page_0")],
173
+ [Button.inline(lang.GROUPS, "groupinfo_page_0")]
174
+ ]
175
+ )
tgfs/plugins/message.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import logging
18
+
19
+ from telethon import events
20
+ from telethon.custom import Message
21
+
22
+ from tgfs.config import Config
23
+ from tgfs.plugins.custom import HANDLERS
24
+ from tgfs.telegram import client
25
+ from tgfs.database import DB
26
+ from tgfs.utils.translation import get_lang, registry
27
+ from tgfs.utils.types import Status, User
28
+ from tgfs.utils.utils import check_get_user, make_token
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+
33
+ @client.on(events.NewMessage(incoming=True, pattern=r"^/start", func=lambda x: x.is_private and not x.file))
34
+ async def handle_start_command(evt: events.NewMessage.Event) -> None:
35
+ msg: Message = evt.message
36
+ user = await check_get_user(msg.sender_id, msg.id)
37
+ if not user:
38
+ return
39
+ lang = get_lang(user)
40
+ await evt.reply(lang.START_TEXT)
41
+
42
+ @client.on(events.NewMessage(incoming=True, pattern=r"^/help", func=lambda x: x.is_private and not x.file))
43
+ async def handle_help_command(evt: events.NewMessage.Event) -> None:
44
+ msg: Message = evt.message
45
+ user = await check_get_user(msg.sender_id, msg.id)
46
+ if not user:
47
+ return
48
+ lang = get_lang(user)
49
+ await evt.reply(lang.HELP_TEXT)
50
+
51
+ @client.on(events.NewMessage(incoming=True, pattern=r"^(?!/).*", func=lambda x: x.is_private and not x.file))
52
+ async def handle_text_message(evt: events.NewMessage.Event) -> None:
53
+ msg: Message = evt.message
54
+ user = await check_get_user(msg.sender_id, msg.id)
55
+ if user is None:
56
+ return
57
+ lang = get_lang(user)
58
+
59
+ message = msg.message.strip()
60
+ if user.curt_op == Status.GROUP_NAME:
61
+ await handle_group_name(evt, user)
62
+ else:
63
+ for pattern, func in HANDLERS:
64
+ m = pattern.match(message)
65
+ if m:
66
+ await func(evt, user, m)
67
+ return
68
+
69
+ await evt.reply(lang.UNKNOWN_COMMAND)
70
+
71
+ async def handle_group_name(evt: events.NewMessage.Event, user: User) -> None:
72
+ lang = get_lang(user)
73
+ msg: Message = evt.message
74
+ name = msg.text.strip()
75
+ group_id = user.op_id
76
+ await DB.db.update_group_name(group_id, user.user_id, name)
77
+ user.curt_op = Status.NO_OP
78
+ user.op_id = 0
79
+ await DB.db.upsert_user(user)
80
+ token = make_token(user.user_id, group_id)
81
+ url = f"{Config.PUBLIC_URL}/group/{token}"
82
+ await evt.reply(lang.GROUP_CREATED_TEXT.format(name=name, url=url))
83
+
84
+ @client.on(events.NewMessage(
85
+ incoming=True,
86
+ pattern=r"^/setln(?:\s+([a-z]{2}))?$",
87
+ func=lambda x: x.is_private and not x.file
88
+ ))
89
+ async def handle_setln_command(evt: events.NewMessage.Event) -> None:
90
+ msg: Message = evt.message
91
+ user = await check_get_user(msg.sender_id, msg.id)
92
+ if user is None:
93
+ return
94
+ lang = get_lang(user)
95
+ ln_code = evt.pattern_match.group(1)
96
+ if ln_code is None or ln_code not in registry:
97
+ await evt.reply(lang.SETLN_USAGE_TEXT.format(supported_codes=", ".join(registry.keys())))
98
+ return
99
+ user.preferred_lang = ln_code
100
+ if await DB.db.upsert_user(user):
101
+ await evt.reply(lang.SETLN_SET_TO.format(ln_code=ln_code))
102
+ else:
103
+ await evt.reply(lang.SOMETHING_WENT_WRONG)
104
+
105
+ @client.on(events.NewMessage(incoming=True, pattern=r"^/cancel", func=lambda x: x.is_private and not x.file))
106
+ async def handle_cancel_command(evt: events.NewMessage.Event, user=None) -> None:
107
+ msg: Message = evt.message
108
+ user = user or await check_get_user(msg.sender_id, msg.id)
109
+ if user is None:
110
+ return
111
+ lang = get_lang(user)
112
+ user.curt_op=Status.NO_OP
113
+ user.op_id=0
114
+ await DB.db.upsert_user(user)
115
+ await evt.reply(lang.OPERATION_CANCELED)
tgfs/routes.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import logging
18
+ import asyncio
19
+ from aiohttp import web
20
+
21
+ from tgfs.config import Config
22
+ from tgfs.paralleltransfer import ParallelTransferrer
23
+ from tgfs.utils.utils import make_token, parse_token, update_location, uptime_human
24
+ from tgfs.telegram import multi_clients
25
+ from tgfs.database import DB
26
+
27
+ log = logging.getLogger(__name__)
28
+ routes = web.RouteTableDef()
29
+
30
+ client_selection_lock = asyncio.Lock()
31
+
32
+ @routes.get("/")
33
+ async def handle_root(_: web.Request):
34
+ return web.json_response({
35
+ 'uptime': uptime_human(),
36
+ 'load': {transfer.client_id: transfer.users for transfer in multi_clients}
37
+ })
38
+
39
+ # @routes.get(r"/{msg_id:-?\d+}/{name}")
40
+ @routes.get("/dl/{payload}/{sig}")
41
+ @routes.get("/wt/{payload}/{sig}")
42
+ async def handle_file_request(req: web.Request, head: bool = None, watch: bool = None) -> web.Response:
43
+ if watch is None:
44
+ watch: bool = True if "wt" in req.path.split("/") else False
45
+ if head is None:
46
+ head: bool = req.method == "HEAD"
47
+ payload = req.match_info["payload"]
48
+ sig = req.match_info["sig"]
49
+ pt = parse_token(payload, sig)
50
+ if not pt:
51
+ return web.Response(status=404, text="File not found")
52
+ user_id, file_id = pt
53
+
54
+ file = await DB.db.get_file(file_id, user_id)
55
+ if not file:
56
+ return web.Response(status=404, text="File not found")
57
+ if file.is_deleted:
58
+ return web.Response(status=451, text="File is restricted")
59
+
60
+ size = file.file_size
61
+ from_bytes = req.http_range.start or 0
62
+ until_bytes = (req.http_range.stop or size) - 1
63
+
64
+ if (until_bytes >= size) or (from_bytes < 0) or (until_bytes < from_bytes):
65
+ return web.Response(status=416, headers={"Content-Range": f"bytes */{size}"})
66
+ if head:
67
+ body=None
68
+ else:
69
+ transfer: ParallelTransferrer = min(multi_clients, key=lambda c: c.users)
70
+ log.debug("Using client %s", transfer.client_id)
71
+ location = await DB.db.get_location(file, transfer.client_id)
72
+ if location is None:
73
+ source = await DB.db.get_source(file.id, user_id)
74
+ location = await update_location(source, transfer)
75
+ body=transfer.download(location, file.dc_id, size, from_bytes, until_bytes)
76
+
77
+ return web.Response(
78
+ status=200 if (from_bytes == 0 and until_bytes == size - 1) else 206,
79
+ body=body,
80
+ headers={
81
+ "Content-Type": file.mime_type,
82
+ "Content-Range": f"bytes {from_bytes}-{until_bytes}/{size}",
83
+ "Content-Length": str(until_bytes - from_bytes + 1),
84
+ "Content-Disposition": f'{"inline" if watch else "attachment"}; filename="{file.file_name}"',
85
+ "Accept-Ranges": "bytes",
86
+ })
87
+
88
+ @routes.get("/group/{payload}/{sig}")
89
+ async def handle_group_request(req: web.Request) -> web.Response:
90
+ payload = req.match_info["payload"]
91
+ sig = req.match_info["sig"]
92
+ pt = parse_token(payload, sig)
93
+ if not pt:
94
+ return web.Response(status=404, text="File not found")
95
+ user_id, group_id = pt
96
+ group = await DB.db.get_group(group_id, user_id)
97
+ if group is None:
98
+ return web.Response(status=404, text="Group not found")
99
+ resp = "".join(f"{Config.PUBLIC_URL}/dl/{make_token(user_id, file_id)}\n" for file_id in group.files)
100
+ return web.Response(status=200, text=resp)
101
+
102
+ @routes.get("/dl/{object_id}", allow_head=True)
103
+ async def stream_handler(request: web.Request):
104
+ object_id = request.match_info["object_id"]
105
+ file = await DB.db.get_file_old(object_id)
106
+ if not file:
107
+ return web.Response(status=404, text="File not found")
108
+ token = make_token(file["user_id"], file["media_id"])
109
+ return web.HTTPFound(f"{Config.PUBLIC_URL}/dl/{token}")
tgfs/telegram.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import asyncio
18
+ import importlib.util
19
+ import logging
20
+
21
+ from pathlib import Path
22
+ import sys
23
+ from typing import Optional
24
+ from telethon import TelegramClient, functions
25
+ from telethon.sessions import MemorySession
26
+ from telethon.tl.types import InputPeerUser
27
+
28
+ from tgfs.config import Config
29
+ from tgfs.paralleltransfer import ParallelTransferrer
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ multi_clients: list[ParallelTransferrer] = []
34
+
35
+ client = TelegramClient(
36
+ "tg-filestream"+Config.SESSION_NAME,
37
+ api_id=Config.API_ID,
38
+ api_hash=Config.API_HASH,
39
+ receive_updates=not Config.NO_UPDATE,
40
+ sequential_updates=Config.SEQUENTIAL_UPDATES
41
+ )
42
+
43
+ async def _start_client(token: str) -> Optional[ParallelTransferrer]:
44
+ bot = TelegramClient(
45
+ MemorySession(),
46
+ api_id=Config.API_ID,
47
+ api_hash=Config.API_HASH,
48
+ receive_updates=False
49
+ )
50
+ try:
51
+ await bot.start(bot_token=token)
52
+ # https://github.com/LonamiWebs/Telethon/blob/59da66e105ba29eee7760538409181859c7d310d/telethon/client/downloads.py#L62
53
+ config = await bot(functions.help.GetConfigRequest())
54
+ for option in config.dc_options:
55
+ if option.ip_address == bot.session.server_address:
56
+ bot.session.set_dc(
57
+ option.id, option.ip_address, option.port)
58
+ bot.session.save()
59
+ break
60
+ me: InputPeerUser = await bot.get_me(True)
61
+ transfer = ParallelTransferrer(bot, me.user_id)
62
+ transfer.post_init()
63
+ return transfer
64
+ except Exception as e: # pylint: disable=W0718
65
+ log.error("Faied to Start Client %s: %s", token.split(":")[0], e)
66
+ return None
67
+
68
+ async def start_clients():
69
+ tasks = (_start_client(t) for t in Config.TOKENS)
70
+ results = await asyncio.gather(*tasks)
71
+ multi_clients.extend(filter(None, results))
72
+
73
+ def load_plugins(folder_path: str) -> None:
74
+ folder = Path(folder_path)
75
+ package_prefix = ".".join(folder.parts)
76
+ for file in folder.glob("*.py"):
77
+ module_name = f"{package_prefix}.{file.stem}"
78
+ if module_name in sys.modules:
79
+ log.debug("Already Imported %s, skipping", module_name)
80
+ continue
81
+ spec = importlib.util.spec_from_file_location(module_name, str(file))
82
+ module = importlib.util.module_from_spec(spec)
83
+ sys.modules[module_name] = module
84
+ module.__package__ = package_prefix
85
+ spec.loader.exec_module(module)
86
+ log.info("Imported %s", module_name)
tgfs/utils/cache_util.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import asyncio
18
+ import logging
19
+
20
+ from collections import OrderedDict
21
+ from typing import Optional, Callable, Awaitable, Any
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ class AsyncLRUCache:
26
+ def __init__(self, fn: Callable[..., Awaitable[Any]], maxsize: Optional[int], use_first_arg: bool) -> None:
27
+ self.fn = fn
28
+ self.maxsize = maxsize
29
+ self.use_first_arg = use_first_arg
30
+ self.cache: OrderedDict[int, asyncio.Task] = OrderedDict()
31
+
32
+ def _make_key(self, args, kwargs) -> int:
33
+ if self.use_first_arg:
34
+ if not args:
35
+ raise ValueError("First argument missing for use_first_arg=True")
36
+ return hash(args[0])
37
+ else:
38
+ key_data = args
39
+ if kwargs:
40
+ key_data += tuple(sorted(kwargs.items()))
41
+ return hash(key_data)
42
+
43
+ async def __call__(self, *args, **kwargs) -> Any:
44
+ key = self._make_key(args, kwargs)
45
+
46
+ if key in self.cache:
47
+ task = self.cache[key]
48
+ self.cache.move_to_end(key)
49
+ return await asyncio.shield(task)
50
+
51
+ async def call() -> Any:
52
+ try:
53
+ return await self.fn(*args, **kwargs)
54
+ except Exception:
55
+ self.cache.pop(key, None)
56
+ raise
57
+
58
+ task = asyncio.create_task(call())
59
+ self.cache[key] = task
60
+
61
+ if self.maxsize is not None and len(self.cache) > self.maxsize:
62
+ self.cache.popitem(last=False)
63
+
64
+ result = await asyncio.shield(task)
65
+ if result is None:
66
+ self.cache.pop(key, None)
67
+ return result
68
+
69
+ def cache_clear(self) -> None:
70
+ self.cache.clear()
71
+
72
+ def lru_cache(maxsize: Optional[int] = 128, use_first_arg: bool = False
73
+ ) -> Callable[[Callable[..., Awaitable[Any]]], AsyncLRUCache]:
74
+ def decorator(fn: Callable[..., Awaitable[Any]]) -> AsyncLRUCache:
75
+ return AsyncLRUCache(fn, maxsize, use_first_arg)
76
+ return decorator
tgfs/utils/config_utils.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+
18
+ from __future__ import annotations
19
+
20
+ from os import environ
21
+ from typing import Any
22
+
23
+ class ConfigBase:
24
+ @staticmethod
25
+ def env_bool(key: str, default: bool = False) -> bool:
26
+ val = environ.get(key)
27
+ if val is None:
28
+ return default
29
+ return val.lower() in ("1", "true", "yes", "on")
30
+
31
+ @staticmethod
32
+ def env_int(key: str, default: int) -> int:
33
+ return int(environ.get(key, default))
34
+
35
+ @staticmethod
36
+ def get_multi_client_tokens() -> list[str]:
37
+ prefix = "MULTI_TOKEN"
38
+ tokens: list[tuple[int, str]] = []
39
+
40
+ for key, value in environ.items():
41
+ if key.startswith(prefix):
42
+ suffix = key[len(prefix):]
43
+ if suffix.isdigit():
44
+ tokens.append((int(suffix), value))
45
+
46
+ tokens.sort(key=lambda x: x[0])
47
+ return [token for _, token in tokens]
48
+
49
+ @staticmethod
50
+ def load_backend_config(
51
+ prefix: str,
52
+ schema: dict[str, tuple[type, Any]],
53
+ required: set[str],
54
+ ) -> dict[str, Any]:
55
+ kwargs: dict[str, Any] = {}
56
+ missing: list[str] = []
57
+
58
+ for key, (typ, default) in schema.items():
59
+ env_key = f"{prefix}_{key.upper()}"
60
+
61
+ if env_key in environ:
62
+ kwargs[key] = typ(environ[env_key])
63
+ elif default is not None:
64
+ kwargs[key] = default
65
+ else:
66
+ missing.append(env_key)
67
+
68
+ if set(missing) & required:
69
+ raise RuntimeError(
70
+ f"Missing required environment variables: {', '.join(missing)}"
71
+ )
72
+
73
+ return kwargs
tgfs/utils/patches.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from collections import defaultdict
3
+ from typing import Callable, Any
4
+ from enum import Enum
5
+
6
+ log = logging.getLogger("events")
7
+
8
+ class Events(Enum):
9
+ STARTUP = "startup"
10
+ AFTER_WEBAPP = "after_webapp"
11
+ REQUEST = "request"
12
+
13
+ HANDLER=dict[Events, list[Callable[..., Any]]]
14
+
15
+ _register_blocking: HANDLER = defaultdict(list)
16
+ _register_background: HANDLER = defaultdict(list)
17
+ _hook_blocking: HANDLER = defaultdict(list)
18
+ _hook_background: HANDLER = defaultdict(list)
tgfs/utils/translation.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ # pylint: disable=C0103
18
+
19
+ from typing import Union
20
+
21
+ from tgfs.utils.types import User
22
+
23
+ class en:
24
+ # ── Start & Help ─────────────────────────────────────────────
25
+ START_TEXT = """Send me any telegram file or photo I will generate a link for it
26
+ Use /help to see available commands."""
27
+
28
+ HELP_TEXT = """
29
+ Available Commands:
30
+ /start - Start the bot
31
+ /help - Show this help message
32
+ /group - Start creating a group of files
33
+ /done - Finish adding files to the group
34
+ /files - List your uploaded files or created groups
35
+ /setln - Change your language
36
+ /cancel - Cancel the current operation
37
+ """
38
+
39
+ ACCEPTED_TOS_TEXT = "You have agreed to the Terms of Service."
40
+
41
+ # ── Errors & State ────────────────────────────────────────────
42
+ UNKNOWN_COMMAND = "Unknown command/operation"
43
+ SOMETHING_WENT_WRONG = "Something went wrong. Please try again later."
44
+ INAVLID_LINK_TEXT = "Invalid link."
45
+ INVALID_PAGE = "Invalid page."
46
+
47
+ ALREADY_IN_OP = "You are already in an operation. Please complete it before starting a new one."
48
+ NOT_IN_OP = "You are not in any operation."
49
+
50
+ FILE_ID_NOT_FOUND = "File with id `{file_id}` not found."
51
+ FILE_LOCATION_NOT_FOUND = "File location not found."
52
+ FILE_NOT_FOUND_TEXT = "File not found."
53
+ GROUP_NOT_FOUND_TEXT = "Group not found."
54
+ OPERATION_CANCELED = "Current operation has been canceled"
55
+
56
+ # ── File & Group Info ─────────────────────────────────────────
57
+ FILES_TEXT = """You have created links for:
58
+ • Files: {total_files}
59
+ • Groups: {total_groups}
60
+
61
+ Select the type of links you want to view.
62
+ """
63
+
64
+ FILE_INFO_TEXT = """File Info:
65
+ ID: {file_id}
66
+ DC ID: {dc_id}
67
+ Size: {file_size}
68
+ MIME Type: {mime_type}
69
+ File Name: {file_name}
70
+ File Type: {file_type}
71
+ Is Restricted: {restricted}"""
72
+
73
+ GROUP_INFO_TEXT = """Group Info:
74
+ Name: {name}
75
+ Created At: {created_at}
76
+ Total Files: {total_files}"""
77
+
78
+ # ── Group Flow ────────────────────────────────────────────────
79
+ GROUP_NAME_TEXT = "Send a name for your group of files"
80
+ GROUP_SENDFILE_TEXT = "Send files to add to the group. When done, send /done"
81
+ GROUP_CREATED_TEXT = "Group '{name}' created!\n{url}"
82
+ GROUP_NOFILES_TEXT = "No files were added to the group. Operation cancelled."
83
+ GROUP_ENDOF_FILES = "End of group files."
84
+
85
+ # ── File Actions ──────────────────────────────────────────────
86
+ CONFIRM_DELETE_TEXT = "Do you really want to delete this {label}?"
87
+ DELETED_SUCCESSFULLY_TEXT = "{label} deleted successfully."
88
+ SELECT_TYPE_OF_FILE = "Select the type of links you want to view."
89
+ GET_FILE_TEXT = "Get File"
90
+
91
+ # ── Lists & Pagination ────────────────────────────────────────
92
+ NO_LABEL_LINKS_TEXT = "You have not generated any {label} links yet."
93
+ NO_LABELS_TEXT = "No {label}s on this page."
94
+ TOTAL_LABEL_COUNT = "You have **{total}** {label}s."
95
+ FILES_BUTTON_CURRENT = "Page {page_no}/{total_pages}"
96
+
97
+ # ── Languages ─────────────────────────────────────────────────
98
+ SETLN_USAGE_TEXT = "/setln <language_code>\nExample: /setln en\nSupported language codes: {supported_codes}"
99
+ SETLN_SET_TO = "Language changed to {ln_code}"
100
+
101
+ # ── Buttons & Labels ──────────────────────────────────────────
102
+ YES = "Yes"
103
+ NO = "No"
104
+ BACK_TEXT = "Back"
105
+
106
+ PHOTO = "Photo"
107
+ DOCUMENT = "Document"
108
+
109
+ DELETE = "Delete"
110
+ OPEN = "Open"
111
+
112
+ DOWNLOAD = "Download"
113
+ WATCH = "Watch"
114
+ FILES = "Files"
115
+ GROUPS = "Groups"
116
+ FILE = "File"
117
+ GROUP = "Group"
118
+ AGREED = "Agreed"
119
+ EXTERNAL_LINK = "External Link"
120
+
121
+ class kn(en):
122
+ # ─�� Start & Help ─────────────────────────────────────────────
123
+ START_TEXT = """ಯಾವುದೇ ಟೆಲಿಗ್ರಾಂ ಕಡತ ಅಥವಾ ಚಿತ್ರವನ್ನು ನನಗೆ ಕಳುಹಿಸಿ, ಅದಕ್ಕೆ ನಾನು ಒಂದು ಲಿಂಕ್ ರಚಿಸುತ್ತೇನೆ.
124
+ ಲಭ್ಯವಿರುವ ಆಜ್ಞೆಗಳನ್ನು ನೋಡಲು /help ಬಳಸಿ."""
125
+
126
+ HELP_TEXT = """
127
+ ಲಭ್ಯವಿರುವ ಆಜ್ಞೆಗಳು:
128
+ /start - ಬಾಟ್ ಅನ್ನು ಪ್ರಾರಂಭಿಸಿ
129
+ /help - ಈ ಸಹಾಯ ಸಂದೇಶವನ್ನು ತೋರಿಸಿ
130
+ /group - ಕಡತಗಳ ಗುಂಪನ್ನು ರಚಿಸಲು ಪ್ರಾರಂಭಿಸಿ
131
+ /done - ಗುಂಪಿಗೆ ಕಡತಗಳನ್ನು ಸೇರಿಸುವುದನ್ನು ಪೂರ್ಣಗೊಳಿಸಿ
132
+ /files - ನೀವು ಕಳುಹಿಸಿದ ಕಡತಗಳ ಮತ್ತು ರಚಿಸಿದ ಗುಂಪುಗಳ ಪಟ್ಟಿ
133
+ /setln - ನಿಮ್ಮ ಭಾಷೆಯನ್ನು ಬದಲಾಯಿಸಲು
134
+ /cancel - ಪ್ರಸ್ತುತ ಕಾರ್ಯಾಚರಣೆಯನ್ನು ರದ್ದುಗೊಳಿಸಲು
135
+ """
136
+
137
+ ACCEPTED_TOS_TEXT = "ನೀವು ಸೇವಾ ನಿಯಮಗಳಿಗೆ ಒಪ್ಪಿಗೆ ನೀಡಿದ್ದೀರಿ."
138
+
139
+ # ── Errors & State ────────────────────────────────────────────
140
+ UNKNOWN_COMMAND = "ಅಪರಿಚಿತ ಆಜ್ಞೆ / ಕಾರ್ಯ"
141
+ SOMETHING_WENT_WRONG = "ಏನೋ ತಪ್ಪಾಗಿದೆ. ದಯವಿಟ್ಟು ನಂತರ ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ."
142
+ INAVLID_LINK_TEXT = "ಅಮಾನ್ಯ ಲಿಂಕ್."
143
+ INVALID_PAGE = "ಅಮಾನ್ಯ ಪುಟ."
144
+
145
+ ALREADY_IN_OP = "ನೀವು ಈಗಾಗಲೇ ಒಂದು ಕಾರ್ಯದಲ್ಲಿದ್ದೀರಿ. ಹೊಸದನ್ನು ಪ್ರಾರಂಭಿಸುವ ಮೊದಲು ಅದನ್ನು ಪೂರ್ಣಗೊಳಿಸಿ."
146
+ NOT_IN_OP = "ನೀವು ಯಾವುದೇ ಕಾರ್ಯದಲ್ಲಿಲ್ಲ."
147
+
148
+ FILE_ID_NOT_FOUND = "`{file_id}` ಗುರುತಿನ ಯಾವುದೇ ಫೈಲ್ ಕಂಡುಬಂದಿಲ್ಲ."
149
+ FILE_LOCATION_NOT_FOUND = "ಕಡತದ ಸ್ಥಳ ಕಂಡುಬಂದಿಲ್ಲ."
150
+ FILE_NOT_FOUND_TEXT = "ಕಡತ ಕಂಡುಬಂದಿಲ್ಲ."
151
+ GROUP_NOT_FOUND_TEXT = "ಗುಂಪು ಕಂಡುಬಂದಿಲ್ಲ."
152
+
153
+ # ── File & Group Info ─────────────────────────────────────────
154
+ FILES_TEXT = """ನೀವು ರಚಿಸಿದ ಲಿಂಕ್‌ಗಳು :
155
+ • ಕಡತಗಳು: {total_files}
156
+ • ಗುಂಪುಗಳು: {total_groups}
157
+
158
+ ನೀವು ನೋಡಲು ಬಯಸುವ ಲಿಂಕ್‌ಗಳ ಪ್ರಕಾರವನ್ನು ಆಯ್ಕೆಮಾಡಿ.
159
+ """
160
+
161
+ FILE_INFO_TEXT = """ಫೈಲ್ ಮಾಹಿತಿ:
162
+ ಕಡತದ ಐಡಿ: {file_id}
163
+ ಡಿಸಿ ಐಡಿ: {dc_id}
164
+ ಗಾತ್ರ: {file_size}
165
+ ಮೈಮ್ ಪ್ರಕಾರ: {mime_type}
166
+ ಕಡತದ ಹೆಸರು: {file_name}
167
+ ಕಡತದ ಪ್ರಕಾರ: {file_type}
168
+ ನಿರ್ಬಂಧಿತವಾಗಿದೆ: {restricted}"""
169
+
170
+ GROUP_INFO_TEXT = """ಗುಂಪು ಮಾಹಿತಿ:
171
+ ಹೆಸರು: {name}
172
+ ರಚಿಸಿದ ದಿನಾಂಕ: {created_at}
173
+ ಒಟ್ಟು ಕಡತಗಳು: {total_files}"""
174
+
175
+ # ── Group Flow ────────────────────────────────────────────────
176
+ GROUP_NAME_TEXT = "ನಿಮ್ಮ ಕಡತಗಳ ಗುಂಪಿಗೆ ಒಂದು ಹೆಸರನ್ನು ಕಳುಹಿಸಿ"
177
+ GROUP_SENDFILE_TEXT = "ಗುಂಪಿಗೆ ಸೇರಿಸಲು ಕಡತಗಳನ್ನು ಕಳುಹಿಸಿ. ಮುಗಿದ ನಂತರ /done ಕಳುಹಿಸಿ"
178
+ GROUP_CREATED_TEXT = "ಗುಂಪು '{name}' ರಚಿಸಲಾಗಿದೆ!\n{url}"
179
+ GROUP_NOFILES_TEXT = "ಗುಂಪಿಗೆ ಯಾವುದೇ ಕಡತಗಳನ್ನು ಸೇರಿಸಲಾಗಿಲ್ಲ. ಕಾರ್ಯವನ್ನು ರದ್ದುಗೊಳಿಸಲಾಗಿದೆ."
180
+ GROUP_ENDOF_FILES = "ಗುಂಪಿನ ಕಡತಗಳ ಅಂತ್ಯ."
181
+
182
+ # ── File Actions ──────────────────────────────────────────────
183
+ CONFIRM_DELETE_TEXT = "ನೀವು ಈ {label} ಅನ್ನು ಅಳಿಸಲು ಖಚಿತವಾಗಿದ್ದೀರಾ?"
184
+ DELETED_SUCCESSFULLY_TEXT = "{label} ಯಶಸ್ವಿಯಾಗಿ ಅಳಿಸಲಾಗಿದೆ."
185
+ SELECT_TYPE_OF_FILE = "ನೀವು ನೋಡಲು ಬಯಸುವ ಲಿಂಕ್‌ಗಳ ಪ್ರಕಾರವನ್ನು ��ಯ್ಕೆಮಾಡಿ."
186
+ GET_FILE_TEXT = "ಕಡತದ ಪಡೆಯಿರಿ"
187
+
188
+ # ── Lists & Pagination ────────────────────────────────────────
189
+ NO_LABEL_LINKS_TEXT = "ನೀವು ಇನ್ನೂ ಯಾವುದೇ {label}ಗಳ ಲಿಂಕ್‌ಗಳನ್ನು ರಚಿಸಿಲ್ಲ."
190
+ NO_LABELS_TEXT = "ಈ ಪುಟದಲ್ಲಿ ಯಾವುದೇ {label}ಗಳಿಲ್ಲ."
191
+ TOTAL_LABEL_COUNT = "ನಿಮ್ಮ ಬಳಿ ಒಟ್ಟು **{total}** {label}ಗಳಿವೆ."
192
+ FILES_BUTTON_CURRENT = "ಪುಟ {page_no}/{total_pages}"
193
+
194
+ # ── Languages ─────────────────────────────────────────────────
195
+ SETLN_USAGE_TEXT = "/setln <language_code>\nಉದಾಹರಣೆ: /setln en\nಬೆಂಬಲಿತ ಭಾಷಾ ಕೋಡ್‌ಗಳು: {supported_codes}"
196
+ SETLN_SET_TO = "ಭಾಷೆಯನ್ನು {ln_code} ಗೆ ಬದಲಾಯಿಸಲಾಗಿದೆ"
197
+
198
+ # ── Buttons & Labels ──────────────────────────────────────────
199
+ YES = "ಹೌದು"
200
+ NO = "ಇಲ್ಲ"
201
+ BACK_TEXT = "ಹಿಂತಿರುಗಿ"
202
+
203
+ PHOTO = "ಚಿತ್ರ"
204
+ DOCUMENT = "ದಾಖಲೆ"
205
+
206
+ DELETE = "ಅಳಿಸಿ"
207
+ OPEN = "ತೆರೆಯಿರಿ"
208
+
209
+ DOWNLOAD = "ಡೌನ್‌ಲೋಡ್"
210
+ WATCH = "ವೀಕ್ಷಿಸಿ"
211
+ FILES = "ಕಡತಗಳು"
212
+ GROUPS = "ಗುಂಪುಗಳು"
213
+ FILE = "ಕಡತ"
214
+ GROUP = "ಗುಂಪು"
215
+ AGREED = "ಒಪ್ಪಿಗೆ"
216
+ EXTERNAL_LINK = "ಬಾಹ್ಯ ಲಿಂಕ್"
217
+
218
+ registry = {
219
+ "en": en,
220
+ "kn": kn
221
+ }
222
+
223
+ def get_lang(iso_code: Union[str, User] = None) -> en:
224
+ if isinstance(iso_code, User):
225
+ iso_code = iso_code.preferred_lang
226
+ return registry.get(iso_code, en)
tgfs/utils/types.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import datetime
18
+ from typing import Optional, Union, Any
19
+ from dataclasses import dataclass
20
+ from enum import Enum
21
+
22
+ from telethon.tl import types
23
+
24
+ from tgfs.config import Config
25
+
26
+ InputTypeLocation = Union[types.InputDocumentFileLocation, types.InputPhotoFileLocation]
27
+ InputMedia = Union[types.Document, types.Photo, types.PhotoEmpty, types.DocumentEmpty]
28
+ SupportedType = Union[bytes, bool, int, str, list, dict]
29
+
30
+ class Status(Enum):
31
+ NO_OP=0
32
+ GROUP=1
33
+ GROUP_NAME=2
34
+
35
+ @dataclass
36
+ class FileInfo:
37
+ __slots__ = ("id", "dc_id", "file_size", "mime_type", "file_name", "thumb_size", "is_deleted")
38
+ id: int
39
+ dc_id: int
40
+ file_size: int
41
+ mime_type: str
42
+ file_name: str
43
+ thumb_size: str
44
+ is_deleted: bool
45
+
46
+ @dataclass
47
+ class FileSource:
48
+ chat_id: int
49
+ message_id: int
50
+ time: Optional[datetime.datetime] = None
51
+
52
+
53
+ @dataclass
54
+ class User:
55
+ user_id: int
56
+ join_date: Optional[datetime.datetime] = None
57
+ ban_date: Optional[datetime.datetime] = None
58
+ warns: int = 0
59
+ preferred_lang: str = "en" # 2-letter ISO code, default en
60
+ curt_op: Optional[Status] = Status.NO_OP
61
+ op_id: Optional[int] = 0
62
+
63
+ @property
64
+ def is_banned(self) -> bool:
65
+ return self.ban_date is not None
66
+
67
+ @property
68
+ def is_admin(self) -> bool:
69
+ return self.user_id in Config.ADMIN_IDS
70
+
71
+ @classmethod
72
+ def from_row(cls, row: dict[str, Any]) -> "User":
73
+
74
+ def parse_dt(val):
75
+ if isinstance(val, datetime.datetime):
76
+ return val
77
+ if isinstance(val, (str, bytes)):
78
+ try:
79
+ s = val.decode() if isinstance(val, bytes) else val
80
+ return datetime.datetime.fromisoformat(s)
81
+ except Exception: # pylint: disable=W0718
82
+ return None
83
+ return None
84
+
85
+ return cls(
86
+ user_id = int(row["user_id"]),
87
+ join_date = parse_dt(row.get("join_date")),
88
+ ban_date = parse_dt(row.get("ban_date")),
89
+ warns = int(row.get("warns", 0)),
90
+ preferred_lang = row.get("preferred_lang") or "en",
91
+ curt_op = Status(row.get("curt_op")),
92
+ op_id = int(row.get("op_id"))
93
+ )
94
+
95
+ @dataclass
96
+ class GroupInfo:
97
+ group_id: int
98
+ user_id: int
99
+ name: str
100
+ created_at: Optional[datetime.datetime]
101
+ files: Optional[list[int]] = None
tgfs/utils/utils.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TG-FileStream
2
+ # Copyright (C) 2025-2026 Deekshith SH
3
+
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU Affero General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU Affero General Public License for more details.
13
+
14
+ # You should have received a copy of the GNU Affero General Public License
15
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+
17
+ import logging
18
+ import sys
19
+ import base64
20
+ import hashlib
21
+ import hmac
22
+ import struct
23
+ import time
24
+ import importlib.metadata
25
+ from typing import Optional, cast
26
+ from pathlib import Path
27
+
28
+ from telethon import Button
29
+ from telethon.utils import get_input_location
30
+ from telethon.tl.custom import Message
31
+
32
+ from tgfs.config import Config
33
+ from tgfs.paralleltransfer import ParallelTransferrer
34
+ from tgfs.utils.types import FileSource, InputTypeLocation, User
35
+ from tgfs.telegram import client
36
+ from tgfs.database import DB
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+ START_TIME = time.monotonic()
41
+
42
+ async def update_location(source: FileSource, transfer: ParallelTransferrer) -> InputTypeLocation:
43
+ message = cast(Message,await client.forward_messages(
44
+ Config.BIN_CHANNEL, source.message_id, source.chat_id, drop_author=True))
45
+ msg = cast(Message, await transfer.client.get_messages(message.chat_id, ids=message.id))
46
+ await message.delete()
47
+ _, location = get_input_location(msg)
48
+ await DB.db.upsert_location(transfer.client_id, location)
49
+ return location
50
+
51
+ async def check_get_user(user_id: int, msg_id, required: bool = True) -> Optional[User]:
52
+ if Config.ALLOWED_IDS and user_id not in Config.ALLOWED_IDS:
53
+ return None
54
+ user = await DB.db.get_user(user_id)
55
+ if required and user is None:
56
+ await client.send_message(
57
+ user_id, "Please agree to the Terms of Service before using the bot.",
58
+ buttons=[[Button.inline('Agree', f'tos_agree_{msg_id}'.encode('utf-8'))]]
59
+ )
60
+ if user is not None and user.is_banned:
61
+ await client.send_message(user_id, "You are banned from using this bot.")
62
+ return None
63
+ return user
64
+
65
+ async def load_configs():
66
+ Config.SECRET = await DB.db.get_secret()
67
+
68
+ def base64_encode(data: bytes) -> str:
69
+ encoded = base64.urlsafe_b64encode(data)
70
+ return encoded.decode('ascii').rstrip("=")
71
+
72
+ def base64_decode(string: str) -> bytes:
73
+ padding = 4 - (len(string) % 4)
74
+ string = string + ("=" * padding)
75
+ return base64.urlsafe_b64decode(string)
76
+
77
+ def make_token(user_id: int, file_id: int) -> str:
78
+ payload = struct.pack(">QQ", user_id, file_id)
79
+ sig = hmac.new(Config.SECRET, payload, hashlib.sha256).digest()
80
+
81
+ token = (
82
+ base64_encode(payload)
83
+ + "/"
84
+ + base64_encode(sig)
85
+ )
86
+ return token
87
+
88
+ def parse_token(p_b64: str, s_b64: Optional[str] = None) -> tuple[int, int] | None:
89
+ try:
90
+ payload = base64_decode(p_b64)
91
+ sig = base64_decode(s_b64)
92
+
93
+ if s_b64 is not None:
94
+ expected = hmac.new(Config.SECRET, payload, hashlib.sha256).digest()
95
+ if not hmac.compare_digest(sig, expected):
96
+ return None
97
+
98
+ user_id, file_id = struct.unpack(">QQ", payload)
99
+ return user_id, file_id
100
+ except Exception: # pylint: disable=W0718
101
+ return None
102
+
103
+ def human_time(seconds: int):
104
+ mins, sec = divmod(seconds, 60)
105
+ hrs, mins = divmod(mins, 60)
106
+ days, hrs = divmod(hrs, 24)
107
+ return f"{days}d {hrs}h {mins}m {sec}s"
108
+
109
+ def uptime_human():
110
+ return human_time(int(time.monotonic() - START_TIME))
111
+
112
+ def human_bytes(size: int) -> str:
113
+ if size == 0:
114
+ return "0 B"
115
+ units = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]
116
+ i = 0
117
+ while size >= 1024 and i < len(units) - 1:
118
+ size /= 1024
119
+ i += 1
120
+ return f"{size:.2f} {units[i]}"
121
+
122
+ def load_patches(patches_path=None):
123
+ logger = log.getChild("patch")
124
+ if patches_path:
125
+ load_local_patches(patches_path, logger)
126
+
127
+ load_entrypoint_plugins(logger)
128
+
129
+ def load_local_patches(patches_path: str, logger: logging.Logger):
130
+ patches_path = Path(patches_path).resolve()
131
+ if not patches_path.exists():
132
+ return
133
+ project_root = patches_path.parent.parent
134
+ if str(project_root) not in sys.path:
135
+ sys.path.insert(0, str(project_root))
136
+
137
+ package_name = f"{patches_path.parent.name}.{patches_path.name}"
138
+
139
+ for item in patches_path.iterdir():
140
+ if item.is_file() and item.suffix == ".py":
141
+ logger.info("Loading %s", item.stem)
142
+ module_name = f"{package_name}.{item.stem}"
143
+ importlib.import_module(module_name)
144
+ elif item.is_dir() and item.name != "__pycache__":
145
+ logger.info("Loading %s", item.name)
146
+ if (item / "__init__.py").exists():
147
+ module_name = f"{package_name}.{item.name}"
148
+ importlib.import_module(module_name)
149
+ else:
150
+ for file in item.rglob("*.py"):
151
+ relative = file.relative_to(patches_path)
152
+ module_parts = relative.with_suffix("").parts
153
+ module_name = ".".join((package_name, *module_parts))
154
+ importlib.import_module(module_name)
155
+
156
+ def load_entrypoint_plugins(logger: logging.Logger):
157
+ for ep in importlib.metadata.entry_points(group="tgfs.plugins"):
158
+ try:
159
+ logger.info("Loading %s", ep.name)
160
+ importlib.import_module(ep.value)
161
+ except Exception as e: # pylint: disable=W0718
162
+ print(f"Failed to load plugin {ep.name}: {e}")