├── LICENSE
├── OLD 2.14.4
└── SteamClip.py
├── README.md
├── SteamClip.ico
├── steamclip.py
└── steamclipWINDOWS.py
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
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 General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/OLD 2.14.4/SteamClip.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import os
3 | import sys
4 | import subprocess
5 | import json
6 | import bz2
7 | import imageio_ffmpeg as iio
8 | import logging
9 | import traceback
10 | import shutil
11 | import tempfile
12 | import glob
13 | from PyQt5.QtWidgets import (
14 | QApplication, QWidget, QVBoxLayout, QHBoxLayout,
15 | QPushButton, QLabel, QGridLayout,
16 | QFrame, QComboBox, QDialog, QTableWidget,
17 | QTableWidgetItem, QHeaderView,
18 | QMessageBox, QFileDialog, QLayout
19 | )
20 | from PyQt5.QtGui import QPixmap, QIcon
21 | from PyQt5.QtCore import Qt
22 | from datetime import datetime
23 |
24 | user_actions = []
25 |
26 | def setup_logging():
27 | log_dir = os.path.join(SteamClipApp.CONFIG_DIR, 'logs')
28 | os.makedirs(log_dir, exist_ok=True)
29 | timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
30 | log_file = os.path.join(log_dir, f"{timestamp}.log")
31 | logging.basicConfig(
32 | filename=log_file,
33 | level=logging.INFO,
34 | format='%(asctime)s %(levelname)s: %(message)s'
35 | )
36 |
37 | def log_user_action(action):
38 | user_actions.append(action)
39 | logging.info(f"User Action: {action}")
40 |
41 | def handle_exception(exc_type, exc_value, exc_traceback):
42 | if issubclass(exc_type, KeyboardInterrupt):
43 | sys.__excepthook__(exc_type, exc_value, exc_traceback)
44 | return
45 | log_dir = os.path.join(SteamClipApp.CONFIG_DIR, 'logs')
46 | os.makedirs(log_dir, exist_ok=True)
47 | timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
48 | log_file = os.path.join(log_dir, f"crash_{timestamp}.log")
49 | with open(log_file, "w") as f:
50 | f.write("User Actions:\n")
51 | for action in user_actions:
52 | f.write(f"- {action}\n")
53 | f.write("\nError Details:\n")
54 | traceback.print_exception(exc_type, exc_value, exc_traceback, file=f)
55 | error_message = f"An unexpected error occurred:\n{exc_value}"
56 | QMessageBox.critical(None, "Critical Error", error_message)
57 |
58 | class SteamClipApp(QWidget):
59 | CONFIG_DIR = os.path.expanduser("~/.config/SteamClip")
60 | CONFIG_FILE = os.path.join(CONFIG_DIR, 'SteamClip.conf')
61 | GAME_IDS_FILE = os.path.join(CONFIG_DIR, 'GameIDs.txt')
62 | GAME_IDS_BZ2_FILE = os.path.join(CONFIG_DIR, 'GameIDs.txt.bz2')
63 | STEAM_API_URL = "https://api.steampowered.com/ISteamApps/GetAppList/v2/"
64 | CURRENT_VERSION = "v2.14.4"
65 |
66 | def __init__(self):
67 | super().__init__()
68 | log_user_action("Application started")
69 | self.setWindowTitle("SteamClip")
70 | self.setGeometry(100, 100, 900, 600)
71 | self.clip_index = 0
72 | self.clip_folders = []
73 | self.original_clip_folders = []
74 | self.game_ids = {}
75 | self.config = self.load_config()
76 | self.default_dir = self.config.get('userdata_path')
77 | self.export_dir = self.config.get('export_path', os.path.expanduser("~/Desktop"))
78 | first_run = not os.path.exists(self.CONFIG_FILE)
79 |
80 | if not self.default_dir:
81 | self.default_dir = self.prompt_steam_version_selection()
82 | if not self.default_dir:
83 | QMessageBox.critical(self, "Critical Error", "Failed to locate Steam userdata directory. Exiting.")
84 | sys.exit(1)
85 |
86 | self.save_config(self.default_dir, self.export_dir)
87 | self.load_game_ids()
88 | self.selected_clips = set()
89 | self.setup_ui()
90 | self.del_invalid_clips()
91 | self.populate_steamid_dirs()
92 | self.perform_update_check()
93 |
94 | if first_run:
95 | QMessageBox.information(self, "INFO",
96 | "Clips will be saved on the Desktop. You can change the export path in the settings")
97 |
98 | def load_config(self):
99 | config = {'userdata_path': None, 'export_path': os.path.expanduser("~/Desktop")}
100 | if os.path.exists(self.CONFIG_FILE):
101 | with open(self.CONFIG_FILE, 'r') as f:
102 | lines = f.readlines()
103 | for line in lines:
104 | line = line.strip()
105 | if not line or line.startswith('#'):
106 | continue
107 | if '=' in line:
108 | key, value = line.split('=', 1)
109 | key = key.strip()
110 | value = value.strip()
111 | if key == 'userdata_path':
112 | config['userdata_path'] = value
113 | elif key == 'export_path':
114 | config['export_path'] = value
115 | else:
116 | logging.warning(f"Malformed config line (missing '='): {line}")
117 | return config
118 |
119 | def save_config(self, userdata_path=None, export_path=None):
120 | config = {}
121 | if userdata_path:
122 | config['userdata_path'] = userdata_path
123 | config['export_path'] = export_path or os.path.expanduser("~/Desktop")
124 | with open(self.CONFIG_FILE, 'w') as f:
125 | for key, value in config.items():
126 | f.write(f"{key}={value}\n")
127 |
128 | def moveEvent(self, event):
129 | super().moveEvent(event)
130 | for combo_box in [self.steamid_combo, self.gameid_combo, self.media_type_combo]:
131 | if combo_box.view().isVisible():
132 | combo_box.hidePopup()
133 |
134 | def perform_update_check(self, show_message=True):
135 | latest_release = self.get_latest_release_from_github()
136 | if latest_release is None:
137 | return None
138 | if latest_release != self.CURRENT_VERSION and show_message:
139 | self.prompt_update(latest_release)
140 | return latest_release
141 |
142 | def download_update(self, latest_release):
143 | self.wait_message = QDialog(self)
144 | self.wait_message.setWindowTitle("Updating SteamClip")
145 | self.wait_message.setFixedSize(400, 120)
146 | layout = QVBoxLayout()
147 | layout.setAlignment(Qt.AlignCenter)
148 | self.progress_label = QLabel("Downloading update... 0.0%")
149 | self.progress_label.setAlignment(Qt.AlignCenter)
150 | layout.addWidget(self.progress_label)
151 | progress_frame = QFrame()
152 | progress_frame.setFixedSize(300, 30)
153 | progress_frame.setStyleSheet("background-color: #e0e0e0; border-radius: 5px;")
154 | self.progress_inner = QFrame(progress_frame)
155 | self.progress_inner.setGeometry(0, 0, 0, 30)
156 | self.progress_inner.setStyleSheet("background-color: #4caf50; border-radius: 5px;")
157 | layout.addWidget(progress_frame)
158 | cancel_button = QPushButton("Cancel Download")
159 | cancel_button.clicked.connect(lambda: self.cancel_download(temp_download_path))
160 | layout.addWidget(cancel_button)
161 | self.wait_message.setLayout(layout)
162 | self.wait_message.show()
163 | download_url = f"https://github.com/Nastas95/SteamClip/releases/download/{latest_release}/steamclip"
164 | temp_download_path = os.path.join(self.CONFIG_DIR, "steamclip_new")
165 | current_executable = os.path.abspath(sys.argv[0])
166 | command = ['curl', '-L', '--output', temp_download_path, download_url, '--progress-bar', '--max-time', '120']
167 | try:
168 | self.download_process = subprocess.Popen(
169 | command,
170 | stdout=subprocess.PIPE,
171 | stderr=subprocess.PIPE,
172 | text=True
173 | )
174 | while True:
175 | output = self.download_process.stderr.readline()
176 | if output == '' and self.download_process.poll() is not None:
177 | break
178 | if "%" in output:
179 | try:
180 | percentage = output.strip().split()[1].replace('%', '')
181 | percentage = float(percentage)
182 | self.progress_label.setText(f"Downloading update... {percentage}%")
183 | progress_width = int(300 * (percentage / 100))
184 | self.progress_inner.setFixedWidth(progress_width)
185 | except (IndexError, ValueError):
186 | pass
187 | QApplication.processEvents()
188 | if self.wait_message.isHidden():
189 | self.cancel_download(temp_download_path)
190 | return
191 | if self.download_process.returncode != 0:
192 | raise subprocess.CalledProcessError(self.download_process.returncode, command)
193 | os.replace(temp_download_path, current_executable)
194 | self.wait_message.close()
195 | sys.exit(0)
196 | except Exception as e:
197 | self.wait_message.close()
198 | QMessageBox.critical(self, "Update Failed", f"Failed to update SteamClip: {e}")
199 |
200 | def cancel_download(self, temp_download_path):
201 | if hasattr(self, '_is_cancelled') and self._is_cancelled:
202 | return
203 | self._is_cancelled = True
204 | if hasattr(self, 'download_process') and self.download_process.poll() is None:
205 | self.download_process.terminate()
206 | self.download_process.wait()
207 | if os.path.exists(temp_download_path):
208 | os.remove(temp_download_path)
209 | self.wait_message.close()
210 | QMessageBox.information(self, "Download Cancelled", "The update has been cancelled.")
211 |
212 | def get_latest_release_from_github(self):
213 | url = "https://api.github.com/repos/Nastas95/SteamClip/releases/latest"
214 | command = ['curl', '-s', url]
215 | try:
216 | result = subprocess.run(command, capture_output=True, check=True, text=True)
217 | latest_release_info = json.loads(result.stdout)
218 | return latest_release_info['tag_name']
219 | except subprocess.CalledProcessError as e:
220 | print(f"Error fetching latest release: {e}")
221 | return None
222 | except json.JSONDecodeError as e:
223 | print(f"Error decoding JSON: {e}")
224 | return None
225 |
226 | def prompt_update(self, latest_release):
227 | reply = QMessageBox.question(
228 | self,
229 | "Update Available",
230 | f"A new update ({latest_release}) is available. Update now?",
231 | QMessageBox.Yes | QMessageBox.No,
232 | QMessageBox.No
233 | )
234 | if reply == QMessageBox.Yes:
235 | self.download_update(latest_release)
236 |
237 | def check_and_load_userdata_folder(self):
238 | if not os.path.exists(self.CONFIG_FILE):
239 | return self.prompt_steam_version_selection()
240 | with open(self.CONFIG_FILE, 'r') as f:
241 | userdata_path = f.read().strip()
242 | return userdata_path if os.path.isdir(userdata_path) else self.prompt_steam_version_selection()
243 |
244 | def prompt_steam_version_selection(self):
245 | dialog = SteamVersionSelectionDialog(self)
246 | while dialog.exec_() == QDialog.Accepted:
247 | selected_option = dialog.get_selected_option()
248 | if selected_option == "Standard":
249 | userdata_path = os.path.expanduser("~/.local/share/Steam/userdata")
250 | elif selected_option == "Flatpak":
251 | userdata_path = os.path.expanduser("~/.var/app/com.valvesoftware.Steam/data/Steam/userdata")
252 | elif os.path.isdir(selected_option):
253 | userdata_path = selected_option
254 | else:
255 | continue
256 | if os.path.isdir(userdata_path):
257 | self.save_default_directory(userdata_path)
258 | return userdata_path
259 | else:
260 | QMessageBox.warning(self, "Invalid Directory", "The selected directory is not valid. Please select again.")
261 | return None
262 |
263 | def save_default_directory(self, directory):
264 | os.makedirs(self.CONFIG_DIR, exist_ok=True)
265 | with open(self.CONFIG_FILE, 'w') as f:
266 | f.write(directory)
267 |
268 | def load_game_ids(self):
269 | if not os.path.exists(self.GAME_IDS_BZ2_FILE):
270 | QMessageBox.information(self, "Info", "SteamClip will now try to download the GameID database. Please, be patient.")
271 | self.fetch_game_ids()
272 | try:
273 | with bz2.open(self.GAME_IDS_BZ2_FILE, 'rt', encoding='utf-8') as f:
274 | data = json.load(f)
275 | self.game_ids = {str(game['appid']): game['name'] for game in data.get('applist', {}).get('apps', [])}
276 | self.load_custom_game_ids()
277 | except (json.JSONDecodeError, KeyError) as e:
278 | self.show_error(f"Error loading Game IDs: {e}")
279 | self.game_ids = {}
280 |
281 | def load_custom_game_ids(self):
282 | custom_game_ids_file = os.path.join(self.CONFIG_DIR, 'CustomGameIDs.json')
283 | if os.path.exists(custom_game_ids_file):
284 | with open(custom_game_ids_file, 'r') as f:
285 | custom_game_ids = json.load(f)
286 | self.game_ids.update(custom_game_ids)
287 |
288 | def get_game_name(self, game_id):
289 | return self.game_ids.get(game_id, f"GameID {game_id}")
290 |
291 | def setup_ui(self):
292 | self.setStyleSheet("QComboBox { combobox-popup: 0; }")
293 | self.steamid_combo = QComboBox()
294 | self.gameid_combo = QComboBox()
295 | self.media_type_combo = QComboBox()
296 | self.steamid_combo.setFixedSize(300, 40)
297 | self.gameid_combo.setFixedSize(300, 40)
298 | self.media_type_combo.setFixedSize(300, 40)
299 | self.media_type_combo.addItems(["All Clips", "Manual Clips", "Background Clips"])
300 | self.media_type_combo.setCurrentIndex(0)
301 | self.steamid_combo.currentIndexChanged.connect(self.on_steamid_selected)
302 | self.gameid_combo.currentIndexChanged.connect(self.filter_clips_by_gameid)
303 | self.media_type_combo.currentIndexChanged.connect(self.filter_media_type)
304 | self.clip_frame, self.clip_grid = self.create_clip_layout()
305 | self.clear_selection_button = self.create_button("Clear Selection", self.clear_selection, enabled=False, size=(150, 40))
306 | self.export_all_button = self.create_button("Export All", self.export_all, enabled=True, size=(150, 40))
307 | ### self.debug_button = self.create_button("Debug Crash", self.debug_crash, enabled=True, size=(150, 40)) #DEBUG ONLY
308 | self.clear_selection_layout = QHBoxLayout()
309 | self.clear_selection_layout.addStretch()
310 | self.clear_selection_layout.addWidget(self.clear_selection_button)
311 | self.clear_selection_layout.addWidget(self.export_all_button)
312 | self.clear_selection_layout.addStretch()
313 | ### self.clear_selection_layout.addWidget(self.debug_button) #DEBUG ONLY
314 | self.settings_button = self.create_button("", self.open_settings, icon="preferences-system", size=(40, 40))
315 | self.id_selection_layout = QHBoxLayout()
316 | self.id_selection_layout.addWidget(self.settings_button)
317 | self.id_selection_layout.addWidget(self.steamid_combo)
318 | self.id_selection_layout.addWidget(self.gameid_combo)
319 | self.id_selection_layout.addWidget(self.media_type_combo)
320 | self.main_layout = QVBoxLayout()
321 | self.main_layout.addLayout(self.id_selection_layout)
322 | self.main_layout.addWidget(self.clip_frame)
323 | self.main_layout.addLayout(self.clear_selection_layout)
324 | self.bottom_layout = self.create_bottom_layout()
325 | self.main_layout.addLayout(self.bottom_layout)
326 | self.setLayout(self.main_layout)
327 | self.main_layout.setSizeConstraint(QLayout.SetFixedSize)
328 | self.status_label = QLabel("")
329 | self.status_label.setAlignment(Qt.AlignCenter)
330 | self.main_layout.addWidget(self.status_label)
331 |
332 | def create_clip_layout(self):
333 | clip_grid = QGridLayout()
334 | clip_frame = QFrame()
335 | clip_frame.setLayout(clip_grid)
336 | return clip_frame, clip_grid
337 |
338 | def create_bottom_layout(self):
339 | self.convert_button = self.create_button("Convert Clip(s)", self.convert_clip, enabled=False)
340 | self.exit_button = self.create_button("Exit", self.close)
341 | self.prev_button = self.create_button("<< Previous", self.show_previous_clips)
342 | self.next_button = self.create_button("Next >>", self.show_next_clips)
343 | bottom_layout = QHBoxLayout()
344 | bottom_layout.addWidget(self.prev_button)
345 | bottom_layout.addWidget(self.next_button)
346 | bottom_layout.addWidget(self.convert_button)
347 | bottom_layout.addWidget(self.exit_button)
348 | return bottom_layout
349 |
350 | def create_button(self, text, slot, enabled=True, icon=None, size=(240, 40)):
351 | button = QPushButton(text)
352 | button.clicked.connect(slot)
353 | button.setEnabled(enabled)
354 | if icon:
355 | button.setIcon(QIcon.fromTheme(icon))
356 | if size:
357 | button.setFixedSize(*size)
358 | return button
359 |
360 | def is_connected(self):
361 | try:
362 | output = subprocess.run(["ping", "-c", "1", "1.1.1.1"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
363 | return output.returncode == 0
364 | except Exception as e:
365 | print(f"Ping failed: {e}")
366 | return False
367 |
368 | def fetch_game_ids(self):
369 | command = ['curl', '-s', self.STEAM_API_URL]
370 | try:
371 | result = subprocess.run(command, capture_output=True, check=True)
372 | with bz2.open(self.GAME_IDS_BZ2_FILE, 'wt', encoding='utf-8') as f:
373 | f.write(result.stdout.decode('utf-8'))
374 | self.show_info("Game IDs Downloaded in config folder")
375 | except subprocess.CalledProcessError as e:
376 | self.show_error(f"Failed to fetch game names from Steam API: {e}")
377 |
378 | def get_custom_record_path(self, userdata_dir):
379 | localconfig_path = os.path.join(userdata_dir, 'config', 'localconfig.vdf')
380 | if not os.path.exists(localconfig_path):
381 | return None
382 | with open(localconfig_path, 'r', encoding='utf-8', errors='ignore') as f:
383 | lines = f.readlines()
384 | for i, line in enumerate(lines):
385 | line = line.strip()
386 | if '"BackgroundRecordPath"' in line:
387 | parts = line.split('"BackgroundRecordPath"')
388 | if len(parts) > 1:
389 | path_line = parts[1].strip()
390 | path_line = path_line.strip('" ')
391 | if path_line:
392 | return path_line
393 | return None
394 |
395 | def del_invalid_clips(self):
396 | invalid_folders = []
397 | for steamid_entry in os.scandir(self.default_dir):
398 | if steamid_entry.is_dir() and steamid_entry.name.isdigit():
399 | userdata_dir = steamid_entry.path
400 | clips_dirs = []
401 | default_clips = os.path.join(userdata_dir, 'gamerecordings', 'clips')
402 | default_video = os.path.join(userdata_dir, 'gamerecordings', 'video')
403 | if os.path.isdir(default_clips):
404 | clips_dirs.append(default_clips)
405 | if os.path.isdir(default_video):
406 | clips_dirs.append(default_video)
407 | custom_path = self.get_custom_record_path(userdata_dir)
408 | if custom_path:
409 | custom_clips = os.path.join(custom_path, 'clips')
410 | custom_video = os.path.join(custom_path, 'video')
411 | if os.path.isdir(custom_clips):
412 | clips_dirs.append(custom_clips)
413 | if os.path.isdir(custom_video):
414 | clips_dirs.append(custom_video)
415 | for clip_dir in clips_dirs:
416 | for folder_entry in os.scandir(clip_dir):
417 | if folder_entry.is_dir() and "_" in folder_entry.name:
418 | folder_path = folder_entry.path
419 | if not self.find_session_mpd(folder_path):
420 | invalid_folders.append(folder_path)
421 | if invalid_folders:
422 | reply = QMessageBox.question(
423 | self,
424 | "Invalid Clips Found",
425 | f"Found {len(invalid_folders)} invalid clip(s). Delete them?",
426 | QMessageBox.Yes | QMessageBox.No,
427 | QMessageBox.No
428 | )
429 | if reply == QMessageBox.Yes:
430 | success = 0
431 | for folder in invalid_folders:
432 | try:
433 | shutil.rmtree(folder)
434 | log_user_action(f"Deleted invalid clip folder: {folder}")
435 | success += 1
436 | except Exception as e:
437 | self.show_error(f"Failed to delete {folder}: {str(e)}")
438 | self.show_info(f"Deleted {success} invalid clip(s).")
439 | self.populate_steamid_dirs()
440 |
441 | def filter_media_type(self):
442 | selected_media_type = self.media_type_combo.currentText()
443 | selected_steamid = self.steamid_combo.currentText()
444 | if not selected_steamid:
445 | return
446 | userdata_dir = os.path.join(self.default_dir, selected_steamid)
447 | custom_record_path = self.get_custom_record_path(userdata_dir)
448 | clips_dir_default = os.path.join(userdata_dir, 'gamerecordings', 'clips')
449 | video_dir_default = os.path.join(userdata_dir, 'gamerecordings', 'video')
450 | clips_dir_custom = os.path.join(custom_record_path, 'clips') if custom_record_path else None
451 | video_dir_custom = os.path.join(custom_record_path, 'video') if custom_record_path else None
452 | clip_folders = []
453 | video_folders = []
454 | if os.path.isdir(clips_dir_default):
455 | clip_folders.extend(folder.path for folder in os.scandir(clips_dir_default) if folder.is_dir() and "_" in folder.name)
456 | if os.path.isdir(video_dir_default):
457 | video_folders.extend(folder.path for folder in os.scandir(video_dir_default) if folder.is_dir() and "_" in folder.name)
458 | if clips_dir_custom and os.path.isdir(clips_dir_custom):
459 | clip_folders.extend(folder.path for folder in os.scandir(clips_dir_custom) if folder.is_dir() and "_" in folder.name)
460 | if video_dir_custom and os.path.isdir(video_dir_custom):
461 | video_folders.extend(folder.path for folder in os.scandir(video_dir_custom) if folder.is_dir() and "_" in folder.name)
462 | if selected_media_type == "All Clips":
463 | self.clip_folders = clip_folders + video_folders
464 | elif selected_media_type == "Manual Clips":
465 | self.clip_folders = clip_folders
466 | elif selected_media_type == "Background Recordings":
467 | self.clip_folders = video_folders
468 | self.clip_folders = sorted(self.clip_folders, key=lambda x: self.extract_datetime_from_folder_name(x), reverse=True)
469 | self.original_clip_folders = list(self.clip_folders)
470 | self.populate_gameid_combo()
471 | self.display_clips()
472 |
473 | def on_steamid_selected(self):
474 | selected_steamid = self.steamid_combo.currentText()
475 | log_user_action(f"Selected SteamID: {selected_steamid}")
476 | userdata_dir = os.path.join(self.default_dir, selected_steamid)
477 | self.filter_media_type()
478 |
479 | def clear_clip_grid(self):
480 | for i in range(self.clip_grid.count()):
481 | widget = self.clip_grid.itemAt(i).widget()
482 | if widget:
483 | widget.deleteLater()
484 |
485 | def clear_selection(self):
486 | log_user_action("Cleared selection of clips")
487 | self.selected_clips.clear()
488 | for i in range(self.clip_grid.count()):
489 | widget = self.clip_grid.itemAt(i).widget()
490 | if widget and hasattr(widget, 'folder'):
491 | widget.setStyleSheet("border: none;")
492 | self.convert_button.setEnabled(False)
493 | self.clear_selection_button.setEnabled(False)
494 |
495 | def populate_steamid_dirs(self):
496 | if not os.path.isdir(self.default_dir):
497 | self.show_error("Default Steam userdata directory not found.")
498 | return
499 | self.steamid_combo.clear()
500 | steamid_found = False
501 | for entry in os.scandir(self.default_dir):
502 | if entry.is_dir() and entry.name.isdigit():
503 | clips_dir = os.path.join(self.default_dir, entry.name, 'gamerecordings', 'clips')
504 | video_dir = os.path.join(self.default_dir, entry.name, 'gamerecordings', 'video')
505 | if os.path.isdir(clips_dir) or os.path.isdir(video_dir):
506 | self.steamid_combo.addItem(entry.name)
507 | steamid_found = True
508 | if not steamid_found:
509 | QMessageBox.warning(
510 | self,
511 | "No Clips Found",
512 | "Clips folder is empty. Record at least one clip to use SteamClip."
513 | )
514 | sys.exit()
515 | self.update_media_type_combo()
516 |
517 | def update_media_type_combo(self):
518 | selected_steamid = self.steamid_combo.currentText()
519 | if not selected_steamid:
520 | return
521 | userdata_dir = os.path.join(self.default_dir, selected_steamid)
522 | clips_dir = os.path.join(userdata_dir, 'gamerecordings', 'clips')
523 | video_dir = os.path.join(userdata_dir, 'gamerecordings', 'video')
524 | self.media_type_combo.clear()
525 | if os.path.isdir(clips_dir) and os.path.isdir(video_dir):
526 | self.media_type_combo.addItems(["All Clips", "Manual Clips", "Background Recordings"])
527 | elif os.path.isdir(clips_dir):
528 | self.media_type_combo.addItems(["Manual Clips"])
529 | elif os.path.isdir(video_dir):
530 | self.media_type_combo.addItems(["Background Recordings"])
531 | self.media_type_combo.setCurrentIndex(0)
532 |
533 | def extract_datetime_from_folder_name(self, folder_name):
534 | parts = folder_name.split('_')
535 | if len(parts) >= 3:
536 | try:
537 | datetime_str = parts[-2] + parts[-1]
538 | return datetime.strptime(datetime_str, "%Y%m%d%H%M%S")
539 | except ValueError:
540 | pass
541 | return datetime.min
542 |
543 | def populate_gameid_combo(self):
544 | game_ids_in_clips = {folder.split('_')[1] for folder in self.clip_folders}
545 | sorted_game_ids = sorted(game_ids_in_clips)
546 | self.gameid_combo.clear()
547 | self.gameid_combo.addItem("All Games")
548 | for game_id in sorted_game_ids:
549 | self.gameid_combo.addItem(self.get_game_name(game_id), game_id)
550 |
551 | def filter_clips_by_gameid(self):
552 | selected_index = self.gameid_combo.currentIndex()
553 | if selected_index == 0:
554 | log_user_action("Selected All Games")
555 | self.clip_folders = [folder for folder in self.original_clip_folders if self.find_session_mpd(folder)]
556 | else:
557 | selected_game_id = self.gameid_combo.itemData(selected_index)
558 | game_name = self.get_game_name(selected_game_id)
559 | log_user_action(f"Selected Game: {game_name} (ID: {selected_game_id})")
560 | self.clip_folders = [
561 | folder for folder in self.original_clip_folders
562 | if f'_{selected_game_id}_' in folder and self.find_session_mpd(folder)
563 | ]
564 | self.clip_index = 0
565 | self.display_clips()
566 |
567 | def display_clips(self):
568 | self.clear_clip_grid()
569 | valid_clip_folders = [
570 | folder for folder in self.clip_folders[self.clip_index:]
571 | if self.find_session_mpd(folder)
572 | ]
573 | clips_to_show = valid_clip_folders[:6]
574 | for index, folder in enumerate(clips_to_show):
575 | session_mpd_file = self.find_session_mpd(folder)
576 | thumbnail_path = os.path.join(folder, 'thumbnail.jpg')
577 | if session_mpd_file and not os.path.exists(thumbnail_path):
578 | self.extract_first_frame(session_mpd_file, thumbnail_path)
579 | if os.path.exists(thumbnail_path):
580 | self.add_thumbnail_to_grid(thumbnail_path, folder, index)
581 | placeholders_needed = 6 - len(clips_to_show)
582 | for i in range(placeholders_needed):
583 | placeholder = QFrame()
584 | placeholder.setFixedSize(300, 180)
585 | placeholder.setStyleSheet("border: none; background-color: transparent;")
586 | self.clip_grid.addWidget(placeholder, (len(clips_to_show) + i) // 3, (len(clips_to_show) + i) % 3)
587 | for i in range(self.clip_grid.count()):
588 | widget = self.clip_grid.itemAt(i).widget()
589 | if widget and hasattr(widget, 'folder') and widget.folder in self.selected_clips:
590 | widget.setStyleSheet("border: 3px solid lightblue;")
591 | self.update_navigation_buttons()
592 | self.export_all_button.setEnabled(bool(self.clip_folders))
593 |
594 | def extract_first_frame(self, session_mpd_path, output_thumbnail_path):
595 | ffmpeg_path = iio.get_ffmpeg_exe()
596 | command = [
597 | ffmpeg_path,
598 | '-i', session_mpd_path,
599 | '-ss', '00:00:00.000',
600 | '-vframes', '1',
601 | output_thumbnail_path
602 | ]
603 | try:
604 | subprocess.run(command, check=True)
605 | except subprocess.CalledProcessError as e:
606 | print(f"Error extracting thumbnail: {e}")
607 |
608 | def add_thumbnail_to_grid(self, thumbnail_path, folder, index):
609 | container = QFrame()
610 | container.setFixedSize(300, 180)
611 | container_layout = QVBoxLayout()
612 | container_layout.setContentsMargins(0, 0, 0, 0)
613 | container.setLayout(container_layout)
614 | pixmap = QPixmap(thumbnail_path).scaled(300, 180, Qt.KeepAspectRatio)
615 | thumbnail_label = QLabel()
616 | thumbnail_label.setPixmap(pixmap)
617 | thumbnail_label.setAlignment(Qt.AlignCenter)
618 | thumbnail_label.setStyleSheet("border: none; padding: 0; margin: 0;")
619 | container_layout.addWidget(thumbnail_label)
620 | container.folder = folder
621 |
622 | def select_clip_event(event):
623 | self.select_clip(folder, container)
624 | thumbnail_label.mousePressEvent = select_clip_event
625 | self.clip_grid.addWidget(container, index // 3, index % 3)
626 | container_layout.addWidget(thumbnail_label)
627 |
628 | def select_clip(self, folder, container):
629 | if folder in self.selected_clips:
630 | log_user_action(f"Deselected clip: {folder}")
631 | self.selected_clips.remove(folder)
632 | container.setStyleSheet("border: none;")
633 | else:
634 | log_user_action(f"Selected clip: {folder}")
635 | self.selected_clips.add(folder)
636 | container.setStyleSheet("border: 3px solid lightblue;")
637 | self.convert_button.setEnabled(bool(self.selected_clips))
638 | self.clear_selection_button.setEnabled(len(self.selected_clips) >= 1)
639 |
640 | def update_navigation_buttons(self):
641 | self.prev_button.setEnabled(self.clip_index > 0)
642 | self.next_button.setEnabled(self.clip_index + 6 < len(self.clip_folders))
643 |
644 | def show_previous_clips(self):
645 | if self.clip_index - 6 >= 0:
646 | log_user_action("Navigated to previous clips")
647 | self.clip_index -= 6
648 | self.display_clips()
649 |
650 | def show_next_clips(self):
651 | if self.clip_index + 6 < len(self.clip_folders):
652 | log_user_action("Navigated to next clips")
653 | self.clip_index += 6
654 | self.display_clips()
655 |
656 | def process_clips(self, selected_clips=None, export_all=False):
657 | if self.export_dir is None or not os.path.isdir(self.export_dir):
658 | logging.warning(f"Export directory '{self.export_dir}' not found.")
659 | reply = QMessageBox.critical(
660 | self,
661 | "!WARNING!",
662 | f"Directory '{self.export_dir}' not found.\n \n"
663 | "Use Desktop as export directory?",
664 | QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
665 | QMessageBox.StandardButton.Yes
666 | )
667 | if reply == QMessageBox.StandardButton.Yes:
668 | self.export_dir = os.path.expanduser("~/Desktop")
669 | self.save_config(self.default_dir, self.export_dir)
670 | QMessageBox.information(self, "Info", f"Export path set to: {self.export_dir}")
671 | else:
672 | QMessageBox.warning(self, "Operation Cancelled", "Export operation has been cancelled.")
673 | return
674 | self.status_label.setText("Conversion... please wait. (Don't Panic if it looks stuck)")
675 | QApplication.processEvents()
676 | if export_all:
677 | selected_game_index = self.gameid_combo.currentIndex()
678 | selected_media_type = self.media_type_combo.currentText()
679 | filtered_clips = self.original_clip_folders.copy()
680 | if selected_media_type == "Manual Clips":
681 | filtered_clips = [c for c in filtered_clips if "clips" in c]
682 | elif selected_media_type == "Background Recordings":
683 | filtered_clips = [c for c in filtered_clips if "video" in c]
684 | if selected_game_index > 0:
685 | game_id = self.gameid_combo.itemData(selected_game_index)
686 | filtered_clips = [c for c in filtered_clips if f"_{game_id}_" in c]
687 | clip_list = filtered_clips
688 | else:
689 | clip_list = list(selected_clips) if selected_clips else []
690 | if not clip_list:
691 | self.show_error("No clips to process")
692 | return
693 | output_dir = self.export_dir or os.path.expanduser("~/Desktop")
694 | ffmpeg_path = iio.get_ffmpeg_exe()
695 | errors = False
696 | for clip_folder in clip_list:
697 | try:
698 | session_mpd = self.find_session_mpd(clip_folder)
699 | if not session_mpd:
700 | raise FileNotFoundError("session.mpd not found")
701 | data_dir = os.path.dirname(session_mpd)
702 | init_video = os.path.join(data_dir, 'init-stream0.m4s')
703 | init_audio = os.path.join(data_dir, 'init-stream1.m4s')
704 | if not (os.path.exists(init_video) and os.path.exists(init_audio)):
705 | raise FileNotFoundError("Initialization files missing")
706 | with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp_video:
707 | with open(init_video, 'rb') as f:
708 | tmp_video.write(f.read())
709 | for chunk in sorted(glob.glob(os.path.join(data_dir, 'chunk-stream0-*.m4s'))):
710 | with open(chunk, 'rb') as f:
711 | tmp_video.write(f.read())
712 | temp_video_path = tmp_video.name
713 | with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp_audio:
714 | with open(init_audio, 'rb') as f:
715 | tmp_audio.write(f.read())
716 | for chunk in sorted(glob.glob(os.path.join(data_dir, 'chunk-stream1-*.m4s'))):
717 | with open(chunk, 'rb') as f:
718 | tmp_audio.write(f.read())
719 | temp_audio_path = tmp_audio.name
720 | game_id = os.path.basename(clip_folder).split('_')[1]
721 | game_name = self.get_game_name(game_id) or "Clip"
722 | output_file = self.get_unique_filename(output_dir, f"{game_name}.mp4")
723 | subprocess.run([
724 | ffmpeg_path,
725 | '-i', temp_video_path,
726 | '-i', temp_audio_path,
727 | '-c', 'copy',
728 | output_file
729 | ], check=True)
730 | except Exception as e:
731 | errors = True
732 | logging.error(f"Error processing {clip_folder}: {str(e)}")
733 | finally:
734 | try:
735 | if 'temp_video_path' in locals():
736 | os.unlink(temp_video_path)
737 | if 'temp_audio_path' in locals():
738 | os.unlink(temp_audio_path)
739 | except Exception as e:
740 | logging.warning(f"Error cleaning up temp files: {str(e)}")
741 | self.status_label.setText("")
742 | if export_all:
743 | msg = "All clips converted successfully" if not errors else "Some clips failed"
744 | self.show_info(msg)
745 | else:
746 | self.selected_clips.clear()
747 | self.display_clips()
748 | self.show_info("Selected clips converted successfully")
749 | return not errors
750 |
751 | def convert_clip(self):
752 | self.process_clips(selected_clips=self.selected_clips)
753 |
754 | def export_all(self):
755 | self.process_clips(export_all=True)
756 |
757 | def find_session_mpd(self, clip_folder):
758 | for root, _, files in os.walk(clip_folder):
759 | if 'session.mpd' in files:
760 | return os.path.join(root, 'session.mpd')
761 | return None
762 |
763 | def get_unique_filename(self, directory, filename):
764 | base_name, ext = os.path.splitext(filename)
765 | counter = 1
766 | unique_filename = os.path.join(directory, filename)
767 | while os.path.exists(unique_filename):
768 | unique_filename = os.path.join(directory, f"{base_name}_{counter}{ext}")
769 | counter += 1
770 | return unique_filename
771 |
772 | def show_error(self, message):
773 | QMessageBox.critical(self, "Error", message)
774 |
775 | def show_info(self, message):
776 | QMessageBox.information(self, "Info", message)
777 |
778 | def open_settings(self):
779 | self.settings_window = SettingsWindow(self)
780 | self.settings_window.exec_()
781 |
782 | def debug_crash(self):
783 | log_user_action("Debug button pressed - Simulating crash")
784 | raise Exception("Test crash")
785 |
786 |
787 | class SteamVersionSelectionDialog(QDialog):
788 | def __init__(self, parent):
789 | super().__init__(parent)
790 | self.setWindowTitle("Select Steam Version")
791 | self.setFixedSize(300, 150)
792 | layout = QVBoxLayout()
793 | self.standard_button = QPushButton("Standard")
794 | self.flatpak_button = QPushButton("Flatpak")
795 | self.manual_button = QPushButton("Select the userdata folder manually")
796 | self.standard_button.clicked.connect(lambda: self.accept_and_set("Standard"))
797 | self.flatpak_button.clicked.connect(lambda: self.accept_and_set("Flatpak"))
798 | self.manual_button.clicked.connect(self.select_userdata_folder)
799 | layout.addWidget(QLabel("What version of Steam are you using?"))
800 | layout.addWidget(self.standard_button)
801 | layout.addWidget(self.flatpak_button)
802 | layout.addWidget(self.manual_button)
803 | self.setLayout(layout)
804 | self.selected_version = None
805 |
806 | def accept_and_set(self, version):
807 | self.selected_version = version
808 | self.accept()
809 |
810 | def select_userdata_folder(self):
811 | userdata_path = QFileDialog.getExistingDirectory(self, "Select userdata folder")
812 | if userdata_path:
813 | if self.is_valid_userdata_folder(userdata_path):
814 | self.selected_version = userdata_path
815 | self.accept()
816 | else:
817 | QMessageBox.warning(self, "Invalid Directory", "The selected directory is not a valid userdata folder.")
818 |
819 | def is_valid_userdata_folder(self, folder):
820 | if not os.path.basename(folder) == "userdata":
821 | return False
822 | steam_id_dirs = [d for d in os.listdir(folder) if os.path.isdir(os.path.join(folder, d)) and d.isdigit()]
823 | if not steam_id_dirs:
824 | return False
825 | for steam_id in steam_id_dirs:
826 | clips_path = os.path.join(folder, steam_id, 'gamerecordings')
827 | if os.path.isdir(clips_path):
828 | return True
829 | return False
830 |
831 | def get_selected_option(self):
832 | return self.selected_version
833 |
834 |
835 | class SettingsWindow(QDialog):
836 | def __init__(self, parent):
837 | super().__init__(parent)
838 | self.setWindowTitle("Settings")
839 | self.setFixedSize(220, 360)
840 | layout = QVBoxLayout()
841 | self.open_config_button = self.create_button("Open Config Folder", self.open_config_folder, "folder-open")
842 | self.edit_game_ids_button = self.create_button("Edit Game IDs", self.open_edit_game_ids, "edit-rename")
843 | self.update_game_ids_button = self.create_button("Update GameIDs", self.update_game_ids, "view-refresh")
844 | self.check_for_updates_button = self.create_button("Check for Updates", self.check_for_updates_in_settings, "view-refresh")
845 | self.close_settings_button = self.create_button("Close Settings", self.close, "window-close")
846 | self.select_export_button = self.create_button("Set Export Path", self.select_export_path, "folder-open")
847 | self.version_label = QLabel(f"Version: {parent.CURRENT_VERSION}")
848 | self.version_label.setAlignment(Qt.AlignLeft)
849 | self.setLayout(layout)
850 | layout.addWidget(self.open_config_button)
851 | layout.addWidget(self.select_export_button)
852 | layout.addWidget(self.edit_game_ids_button)
853 | layout.addWidget(self.update_game_ids_button)
854 | layout.addWidget(self.check_for_updates_button)
855 | layout.addWidget(self.close_settings_button)
856 | layout.addWidget(self.version_label)
857 |
858 | def select_export_path(self):
859 | export_path = QFileDialog.getExistingDirectory(self, "Set Export Folder")
860 | if export_path and os.path.isdir(export_path):
861 | try:
862 | test_file = os.path.join(export_path, ".test_write_permission")
863 | with open(test_file, 'w') as f:
864 | f.write("test")
865 | os.remove(test_file)
866 | self.parent().export_dir = export_path
867 | self.parent().save_config(self.parent().default_dir, self.parent().export_dir)
868 | QMessageBox.information(self, "Info", f"Export path set to: {export_path}")
869 | return
870 | except Exception as e:
871 | QMessageBox.warning(self, "Invalid Directory",
872 | f"The selected directory is not writable: {str(e)}")
873 | default_export_path = os.path.expanduser("~/Desktop")
874 | self.parent().export_dir = default_export_path
875 | self.parent().save_config(self.parent().default_dir, default_export_path)
876 | QMessageBox.warning(self, "Invalid Directory",
877 | f"Selected export directory is invalid. Using default: {default_export_path}")
878 |
879 | def create_button(self, text, slot, icon=None, size=(200, 45)):
880 | button = QPushButton(text)
881 | button.clicked.connect(slot)
882 | if icon:
883 | button.setIcon(QIcon.fromTheme(icon))
884 | if size:
885 | button.setFixedSize(*size)
886 | return button
887 |
888 | def check_for_updates_in_settings(self):
889 | latest_release = self.parent().perform_update_check(show_message=False)
890 | if latest_release is None:
891 | QMessageBox.critical(self, "Error", "Failed to fetch the latest release information.")
892 | return
893 |
894 | if latest_release == self.parent().CURRENT_VERSION:
895 | QMessageBox.information(self, "No Updates Available",
896 | "You are already using the latest version of SteamClip.")
897 | else:
898 | reply = QMessageBox.question(
899 | self,
900 | "Update Available",
901 | f"A new update ({latest_release}) is available. Update now?",
902 | QMessageBox.Yes | QMessageBox.No,
903 | QMessageBox.No
904 | )
905 | if reply == QMessageBox.Yes:
906 | self.parent().download_update(latest_release)
907 |
908 | def open_edit_game_ids(self):
909 | edit_window = EditGameIDWindow(self.parent())
910 | edit_window.exec_()
911 |
912 | def open_config_folder(self):
913 | config_folder = SteamClipApp.CONFIG_DIR
914 | if sys.platform.startswith('linux'):
915 | subprocess.run(['xdg-open', config_folder])
916 | elif sys.platform == 'darwin':
917 | subprocess.run(['open', config_folder])
918 | elif sys.platform == 'win32':
919 | subprocess.run(['explorer', config_folder])
920 |
921 | def update_game_ids(self):
922 | if not self.parent().is_connected():
923 | QMessageBox.warning(self, "Warning", "Download Failed, GameIDs not updated!")
924 | return
925 | self.parent().fetch_game_ids()
926 | self.parent().load_game_ids()
927 | self.parent().populate_gameid_combo()
928 |
929 |
930 | class EditGameIDWindow(QDialog):
931 | def __init__(self, parent):
932 | super().__init__(parent)
933 | self.setWindowTitle("Edit GameIDs")
934 | self.setFixedSize(400, 300)
935 | self.layout = QVBoxLayout()
936 | self.table_widget = QTableWidget()
937 | self.populate_table()
938 | self.layout.addWidget(self.table_widget)
939 | self.layout.addLayout(self.create_button_layout())
940 | self.setLayout(self.layout)
941 |
942 | def populate_table(self):
943 | self.game_ids = {
944 | self.parent().gameid_combo.itemData(i): self.parent().gameid_combo.itemText(i)
945 | for i in range(self.parent().gameid_combo.count())
946 | }
947 | filtered_game_ids = {game_id: game_name for game_id, game_name in self.game_ids.items() if game_name != "All Games"}
948 | self.table_widget.setRowCount(len(filtered_game_ids))
949 | self.table_widget.setColumnCount(2)
950 | self.table_widget.setHorizontalHeaderLabels(["GameID", "Game Name"])
951 | for row, (game_id, game_name) in enumerate(filtered_game_ids.items()):
952 | self.table_widget.setItem(row, 0, QTableWidgetItem(game_id))
953 | name_item = QTableWidgetItem(game_name)
954 | self.table_widget.setItem(row, 1, name_item)
955 | self.table_widget.horizontalHeader().setStretchLastSection(True)
956 | self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
957 | self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
958 |
959 | def create_button_layout(self):
960 | button_layout = QHBoxLayout()
961 | button_layout.addWidget(self.create_button("Cancel", self.reject))
962 | button_layout.addWidget(self.create_button("Apply Changes", self.save_changes))
963 | return button_layout
964 |
965 | def create_button(self, text, slot):
966 | button = QPushButton(text)
967 | button.clicked.connect(slot)
968 | return button
969 |
970 | def save_changes(self):
971 | custom_game_ids = {
972 | self.table_widget.item(row, 0).text(): self.table_widget.item(row, 1).text()
973 | for row in range(self.table_widget.rowCount()) if self.table_widget.item(row, 0)
974 | }
975 | custom_game_ids_file = os.path.join(SteamClipApp.CONFIG_DIR, 'CustomGameIDs.json')
976 | with open(custom_game_ids_file, 'w') as f:
977 | json.dump(custom_game_ids, f, indent=4)
978 | QMessageBox.information(self, "Info", "Custom GameIDs saved successfully.")
979 | self.parent().load_game_ids()
980 | self.parent().populate_gameid_combo()
981 |
982 |
983 | if __name__ == "__main__":
984 | sys.excepthook = handle_exception
985 | app = QApplication(sys.argv)
986 | app.setStyleSheet("""
987 | QWidget {
988 | font-size: 16px;
989 | }
990 | QLabel {
991 | font-size: 18px;
992 | }
993 | QPushButton {
994 | font-size: 16px;
995 | }
996 | QComboBox {
997 | font-size: 16px;
998 | combobox-popup: 0;
999 | }
1000 | QTableWidget {
1001 | font-size: 16px;
1002 | }
1003 | """)
1004 | try:
1005 | window = SteamClipApp()
1006 | window.show()
1007 | sys.exit(app.exec_())
1008 | except Exception as e:
1009 | handle_exception(type(e), e, e.__traceback__)
1010 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | SteamClip - Steam Recording to MP4 Converter
2 |
3 | SteamClip is a simple PYTHON script that allows you to convert Steam game recordings into .mp4 files
4 |
5 | # **WHY**
6 |
7 | Steam uses m4s file format for video and audio that then are layered in a single video output.
8 |
9 | Exporting to mp4 from Steam itself is possible, but that leads to heavy visual artifacts in my testing.
10 |
11 | Those artifacts are not present when using ffmpeg to convert m4s files to mp4 (or other formats)
12 |
13 | This script was created to save glitch-free .mp4 clips and share them to my phone via Kde connect, especially clips longer than 1 minute
14 |
15 |
16 | # **FEATURES**
17 |
18 | * Converts multiple Steam recordings to MP4 format at once
19 | * Intuitive and user-friendly interface designed for effortless video conversion
20 | * Works by selecting the clip via an interactive prompt
21 | * Saves the final converted file to the Desktop
22 | * Customize GameIDs with user-defined names. This is especially useful for Non-Steam apps like EmuDeck
23 | * Checks for new releases and self updates
24 |
25 | # **INSTALLATION**
26 |
27 | 1. Download SteamClip from the Release page, **steamclip.exe for Windows users** (or clone the repository, follow the build instructions below to set up the script)
28 | 2. Place the SteamClip file in any directory
29 |
30 | Done
31 |
32 | # **USAGE**
33 |
34 | 1. Run SteamClip by double clicking it. Upon launch the program will ask what Steam version you have installed: Standard (from your distro package manager/if you didn't change install directory **on Windows**) or Flatpak (**Linux Only**)
35 | There is an option to manually select your userdata folder, default directory is **~/.local/share/Steam/userdata** on Linux, **C:\Program Files (x86)\Steam** on Windows
36 |
37 | If you have multiple Steam profiles, SteamClip will show you a list with every (valid) SteamID
38 |
39 | 2. After selecting the SteamID, your clips will show up in a 3x2 grid with "Next" and "Previous" button to scroll through different Clips
40 | 3. Select one or more clips from the grid and click "Convert Clip(s)". SteamClip will convert the clip(s) to an MP4 file and save it to your Desktop
41 |
42 | In case of missing **STEAM** Game Name (I.E. New Game release from Steam) you can manually update GameIDs in settings.
43 | **NOTE: You can now set a custom name for ANY app in SteamClip Settings, Non-Steam apps included.**
44 |
45 | Config file is located in **~/.config/SteamClip** on Linux, **C:\Users\YOURUSERNAME\AppData\Local\SteamClip** on Windows
46 |
47 | # **WINDOWS REQUIREMENTS**
48 | - Windows 10 or above
49 | - (*Optional*) Internet connection (**upon launch SteamClip tries to download the Steam appID (GameID) from [this source](https://store.steampowered.com/api/appdetails) and save it to the config folder**)
50 |
51 | # **LINUX REQUIREMENTS**
52 | - Curl (**Already pre-installed basically in every Distribution avaiable**)
53 | - (*Optional*) Internet connection (**upon launch SteamClip tries to download the Steam appID (GameID) from [this source](https://store.steampowered.com/api/appdetails) and save it to the config folder**)
54 |
55 | # DISCLAIMER
56 | SteamClip does **NOT** collect any data. Internet connection is **NOT** a hard requirement.
57 |
58 | # **BUILD INSTRUCTIONS AND REQUIREMENTS**
59 | SteamClip is a simple standalone Python script with Ffmpeg built-in.
60 | Download this repo, put SteamClip.py or SteamClipWINDOWS.py in any same directory, then run
61 | `pyinstaller --onefile --windowed steamclip.py `
62 |
63 | Once the build is complete, you will find the executable inside the **dist** folder.
64 |
65 | ## Requirements
66 | * Python 3.6 or above
67 | * pyinstaller ( `pip install pyinstaller` )
68 | * PyQt5 ( `pip install PyQt5` )
69 | * imageio[ffmpeg] ( `pip install imageio[ffmpeg]` )
70 |
--------------------------------------------------------------------------------
/SteamClip.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nastas95/SteamClip/843ebc432bf7ac313ee265a2b7f29f69f5fd87a1/SteamClip.ico
--------------------------------------------------------------------------------
/steamclip.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import os
3 | import sys
4 | import subprocess
5 | import json
6 | import imageio_ffmpeg as iio
7 | import logging
8 | import traceback
9 | import shutil
10 | import tempfile
11 | import glob
12 | import xml.etree.ElementTree as ET
13 | from PyQt5.QtWidgets import (
14 | QApplication, QWidget, QVBoxLayout, QHBoxLayout,
15 | QPushButton, QLabel, QGridLayout,
16 | QFrame, QComboBox, QDialog, QTableWidget,
17 | QTableWidgetItem, QHeaderView, QTextEdit,
18 | QMessageBox, QFileDialog, QLayout, QProgressBar
19 | )
20 | from PyQt5.QtGui import QPixmap, QIcon
21 | from PyQt5.QtCore import Qt
22 | from datetime import datetime
23 |
24 | user_actions = []
25 |
26 | def setup_logging():
27 | log_dir = os.path.join(SteamClipApp.CONFIG_DIR, 'logs')
28 | os.makedirs(log_dir, exist_ok=True)
29 | timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
30 | log_file = os.path.join(log_dir, f"{timestamp}.log")
31 | logging.basicConfig(
32 | filename=log_file,
33 | level=logging.INFO,
34 | format='%(asctime)s %(levelname)s: %(message)s'
35 | )
36 |
37 | def log_user_action(action):
38 | user_actions.append(action)
39 | logging.info(f"User Action: {action}")
40 |
41 | def handle_exception(exc_type, exc_value, exc_traceback):
42 | if issubclass(exc_type, KeyboardInterrupt):
43 | sys.__excepthook__(exc_type, exc_value, exc_traceback)
44 | return
45 | log_dir = os.path.join(SteamClipApp.CONFIG_DIR, 'logs')
46 | os.makedirs(log_dir, exist_ok=True)
47 | timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
48 | log_file = os.path.join(log_dir, f"crash_{timestamp}.log")
49 | with open(log_file, "w") as f:
50 | f.write("User Actions:\n")
51 | for action in user_actions:
52 | f.write(f"- {action}\n")
53 | f.write("\nError Details:\n")
54 | traceback.print_exception(exc_type, exc_value, exc_traceback, file=f)
55 | error_message = f"An unexpected error occurred:\n{exc_value}"
56 | QMessageBox.critical(None, "Critical Error", error_message)
57 |
58 | class SteamClipApp(QWidget):
59 | CONFIG_DIR = os.path.expanduser("~/.config/SteamClip")
60 | CONFIG_FILE = os.path.join(CONFIG_DIR, 'SteamClip.conf')
61 | GAME_IDS_FILE = os.path.join(CONFIG_DIR, 'GameIDs.json')
62 | STEAM_APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
63 | CURRENT_VERSION = "v2.16.4"
64 |
65 | def __init__(self):
66 | super().__init__()
67 | log_user_action("Application started")
68 | self.setWindowTitle("SteamClip")
69 | self.setGeometry(100, 100, 900, 600)
70 | self.clip_index = 0
71 | self.clip_folders = []
72 | self.original_clip_folders = []
73 | self.game_ids = {}
74 | self.config = self.load_config()
75 | self.default_dir = self.config.get('userdata_path')
76 | self.export_dir = self.config.get('export_path', os.path.expanduser("~/Desktop"))
77 | first_run = not os.path.exists(self.CONFIG_FILE)
78 |
79 | if not self.default_dir:
80 | self.default_dir = self.prompt_steam_version_selection()
81 | if not self.default_dir:
82 | QMessageBox.critical(self, "Critical Error", "Failed to locate Steam userdata directory. Exiting.")
83 | sys.exit(1)
84 |
85 | self.save_config(self.default_dir, self.export_dir)
86 | self.load_game_ids()
87 | self.selected_clips = set()
88 | self.setup_ui()
89 | self.del_invalid_clips()
90 | self.populate_steamid_dirs()
91 | self.perform_update_check()
92 |
93 | if first_run:
94 | QMessageBox.information(self, "INFO",
95 | "Clips will be saved on the Desktop. You can change the export path in the settings")
96 |
97 | def load_config(self):
98 | config = {'userdata_path': None, 'export_path': os.path.expanduser("~/Desktop")}
99 | if os.path.exists(self.CONFIG_FILE):
100 | with open(self.CONFIG_FILE, 'r') as f:
101 | lines = f.readlines()
102 | for line in lines:
103 | line = line.strip()
104 | if not line or line.startswith('#'):
105 | continue
106 | if '=' in line:
107 | key, value = line.split('=', 1)
108 | key = key.strip()
109 | value = value.strip()
110 | if key == 'userdata_path':
111 | config['userdata_path'] = value
112 | elif key == 'export_path':
113 | config['export_path'] = value
114 | else:
115 | logging.warning(f"Malformed config line (missing '='): {line}")
116 | return config
117 |
118 | def save_config(self, userdata_path=None, export_path=None):
119 | config = {}
120 | if userdata_path:
121 | config['userdata_path'] = userdata_path
122 | config['export_path'] = export_path or os.path.expanduser("~/Desktop")
123 | with open(self.CONFIG_FILE, 'w') as f:
124 | for key, value in config.items():
125 | f.write(f"{key}={value}\n")
126 |
127 | def moveEvent(self, event):
128 | super().moveEvent(event)
129 | for combo_box in [self.steamid_combo, self.gameid_combo, self.media_type_combo]:
130 | if combo_box.view().isVisible():
131 | combo_box.hidePopup()
132 |
133 | def perform_update_check(self, show_message=True):
134 | release_info = self.get_latest_release_from_github()
135 | if not release_info:
136 | return None
137 | latest_version = release_info['version']
138 | if latest_version != self.CURRENT_VERSION and show_message:
139 | self.prompt_update(latest_version, release_info['changelog'])
140 | return release_info
141 |
142 | def download_update(self, latest_release):
143 | self.wait_message = QDialog(self)
144 | self.wait_message.setWindowTitle("Updating SteamClip")
145 | self.wait_message.setFixedSize(400, 120)
146 | layout = QVBoxLayout()
147 | layout.setAlignment(Qt.AlignCenter)
148 | self.progress_label = QLabel("Downloading update... 0.0%")
149 | self.progress_label.setAlignment(Qt.AlignCenter)
150 | layout.addWidget(self.progress_label)
151 | progress_frame = QFrame()
152 | progress_frame.setFixedSize(300, 30)
153 | progress_frame.setStyleSheet("background-color: #e0e0e0; border-radius: 5px;")
154 | self.progress_inner = QFrame(progress_frame)
155 | self.progress_inner.setGeometry(0, 0, 0, 30)
156 | self.progress_inner.setStyleSheet("background-color: #4caf50; border-radius: 5px;")
157 | layout.addWidget(progress_frame)
158 | cancel_button = QPushButton("Cancel Download")
159 | cancel_button.clicked.connect(lambda: self.cancel_download(temp_download_path))
160 | layout.addWidget(cancel_button)
161 | self.wait_message.setLayout(layout)
162 | self.wait_message.show()
163 | download_url = f"https://github.com/Nastas95/SteamClip/releases/download/{latest_release}/steamclip"
164 | temp_download_path = os.path.join(self.CONFIG_DIR, "steamclip_new")
165 | current_executable = os.path.abspath(sys.argv[0])
166 | command = ['curl', '-L', '--output', temp_download_path, download_url, '--progress-bar', '--max-time', '120']
167 | try:
168 | self.download_process = subprocess.Popen(
169 | command,
170 | stdout=subprocess.PIPE,
171 | stderr=subprocess.PIPE,
172 | text=True
173 | )
174 | while True:
175 | output = self.download_process.stderr.readline()
176 | if output == '' and self.download_process.poll() is not None:
177 | break
178 | if "%" in output:
179 | try:
180 | percentage = output.strip().split()[1].replace('%', '')
181 | percentage = float(percentage)
182 | self.progress_label.setText(f"Downloading update... {percentage}%")
183 | progress_width = int(300 * (percentage / 100))
184 | self.progress_inner.setFixedWidth(progress_width)
185 | except (IndexError, ValueError):
186 | pass
187 | QApplication.processEvents()
188 | if self.wait_message.isHidden():
189 | self.cancel_download(temp_download_path)
190 | return
191 | if self.download_process.returncode != 0:
192 | raise subprocess.CalledProcessError(self.download_process.returncode, command)
193 | os.replace(temp_download_path, current_executable)
194 | self.wait_message.close()
195 | sys.exit(0)
196 | except Exception as e:
197 | self.wait_message.close()
198 | QMessageBox.critical(self, "Update Failed", f"Failed to update SteamClip: {e}")
199 |
200 | def cancel_download(self, temp_download_path):
201 | if hasattr(self, '_is_cancelled') and self._is_cancelled:
202 | return
203 | self._is_cancelled = True
204 | if hasattr(self, 'download_process') and self.download_process.poll() is None:
205 | self.download_process.terminate()
206 | self.download_process.wait()
207 | if os.path.exists(temp_download_path):
208 | os.remove(temp_download_path)
209 | self.wait_message.close()
210 | QMessageBox.information(self, "Download Cancelled", "The update has been cancelled.")
211 |
212 | def get_latest_release_from_github(self):
213 | url = "https://api.github.com/repos/Nastas95/SteamClip/releases/latest"
214 | try:
215 | result = subprocess.run(['curl', '-s', url], capture_output=True, check=True, text=True)
216 | release_data = json.loads(result.stdout)
217 | return {
218 | 'version': release_data['tag_name'],
219 | 'changelog': release_data.get('body', 'No changelog available')
220 | }
221 | except Exception as e:
222 | logging.error(f"Error fetching release info: {e}")
223 | return None
224 |
225 | def prompt_update(self, latest_version, changelog):
226 | message_box = QMessageBox(QMessageBox.Question, "Update Available",
227 | f"A new update ({latest_version}) is available. Update now?")
228 | update_button = message_box.addButton("Update", QMessageBox.AcceptRole)
229 | changelog_button = message_box.addButton("View Changelog", QMessageBox.ActionRole)
230 | cancel_button = message_box.addButton("Cancel", QMessageBox.RejectRole)
231 | message_box.exec_()
232 | if message_box.clickedButton() == update_button:
233 | self.download_update(latest_version)
234 | elif message_box.clickedButton() == changelog_button:
235 | self.show_changelog(latest_version, changelog)
236 |
237 | def show_changelog(self, latest_version, changelog_text):
238 | dialog = QDialog(self)
239 | dialog.setWindowTitle(f"Changelog - {latest_version}")
240 | dialog.setGeometry(100, 100, 600, 400)
241 | layout = QVBoxLayout()
242 | text_edit = QTextEdit()
243 | text_edit.setReadOnly(True)
244 | text_edit.setMarkdown(changelog_text)
245 | button_layout = QHBoxLayout()
246 | update_button = QPushButton("Update Now")
247 | update_button.clicked.connect(lambda: (dialog.close(), self.download_update(latest_version)))
248 | close_button = QPushButton("Close")
249 | close_button.clicked.connect(dialog.close)
250 | button_layout.addWidget(update_button)
251 | button_layout.addWidget(close_button)
252 | layout.addWidget(text_edit)
253 | layout.addLayout(button_layout)
254 | dialog.setLayout(layout)
255 | dialog.exec_()
256 |
257 | def check_and_load_userdata_folder(self):
258 | if not os.path.exists(self.CONFIG_FILE):
259 | return self.prompt_steam_version_selection()
260 | with open(self.CONFIG_FILE, 'r') as f:
261 | userdata_path = f.read().strip()
262 | return userdata_path if os.path.isdir(userdata_path) else self.prompt_steam_version_selection()
263 |
264 | def prompt_steam_version_selection(self):
265 | dialog = SteamVersionSelectionDialog(self)
266 | while dialog.exec_() == QDialog.Accepted:
267 | selected_option = dialog.get_selected_option()
268 | if selected_option == "Standard":
269 | userdata_path = os.path.expanduser("~/.local/share/Steam/userdata")
270 | elif selected_option == "Flatpak":
271 | userdata_path = os.path.expanduser("~/.var/app/com.valvesoftware.Steam/data/Steam/userdata")
272 | elif os.path.isdir(selected_option):
273 | userdata_path = selected_option
274 | else:
275 | continue
276 | if os.path.isdir(userdata_path):
277 | self.save_default_directory(userdata_path)
278 | return userdata_path
279 | else:
280 | QMessageBox.warning(self, "Invalid Directory", "The selected directory is not valid. Please select again.")
281 | return None
282 |
283 | def save_default_directory(self, directory):
284 | os.makedirs(self.CONFIG_DIR, exist_ok=True)
285 | with open(self.CONFIG_FILE, 'w') as f:
286 | f.write(directory)
287 |
288 | def load_game_ids(self):
289 | if not os.path.exists(self.GAME_IDS_FILE):
290 | QMessageBox.information(self, "Info", "SteamClip will now try to download the GameID database. Please, be patient.")
291 | self.game_ids = {}
292 | else:
293 | with open(self.GAME_IDS_FILE, 'r') as f:
294 | self.game_ids = json.load(f)
295 |
296 | def fetch_game_name_from_steam(self, game_id):
297 | url = f"{self.STEAM_APP_DETAILS_URL}?appids={game_id}&filters=basic"
298 | try:
299 | command = ['curl', '-s', '--compressed', url]
300 | result = subprocess.run(command, capture_output=True, check=True, text=True)
301 | data = json.loads(result.stdout)
302 | if str(game_id) in data and data[str(game_id)]['success']:
303 | return data[str(game_id)]['data']['name']
304 | except Exception as e:
305 | logging.error(f"Error fetching game name for {game_id}: {e}")
306 | return f"{game_id}"
307 | return None
308 |
309 | def get_game_name(self, game_id):
310 | if game_id in self.game_ids:
311 | return self.game_ids[game_id]
312 | if not game_id.isdigit():
313 | default_name = f"{game_id}"
314 | self.game_ids[game_id] = default_name
315 | self.save_game_ids()
316 | return default_name
317 | name = self.fetch_game_name_from_steam(game_id)
318 | if name:
319 | self.game_ids[game_id] = name
320 | self.save_game_ids()
321 | return name
322 | default_name = f"{game_id}"
323 | self.game_ids[game_id] = default_name
324 | self.save_game_ids()
325 | return default_name
326 |
327 | def setup_ui(self):
328 | self.setStyleSheet("QComboBox { combobox-popup: 0; }")
329 | self.steamid_combo = QComboBox()
330 | self.gameid_combo = QComboBox()
331 | self.media_type_combo = QComboBox()
332 | self.steamid_combo.setFixedSize(300, 40)
333 | self.gameid_combo.setFixedSize(300, 40)
334 | self.media_type_combo.setFixedSize(300, 40)
335 | self.media_type_combo.addItems(["All Clips", "Manual Clips", "Background Clips"])
336 | self.media_type_combo.setCurrentIndex(0)
337 | self.steamid_combo.currentIndexChanged.connect(self.on_steamid_selected)
338 | self.gameid_combo.currentIndexChanged.connect(self.filter_clips_by_gameid)
339 | self.media_type_combo.currentIndexChanged.connect(self.filter_media_type)
340 | self.clip_frame, self.clip_grid = self.create_clip_layout()
341 | self.clear_selection_button = self.create_button("Clear Selection", self.clear_selection, enabled=False, size=(150, 40))
342 | self.export_all_button = self.create_button("Export All", self.export_all, enabled=True, size=(150, 40))
343 | self.progress_bar = QProgressBar()
344 | self.progress_bar.setVisible(False)
345 | ### self.debug_button = self.create_button("Debug Crash", self.debug_crash, enabled=True, size=(150, 40)) #DEBUG ONLY
346 | self.clear_selection_layout = QHBoxLayout()
347 | self.clear_selection_layout.addStretch()
348 | self.clear_selection_layout.addWidget(self.clear_selection_button)
349 | self.clear_selection_layout.addWidget(self.export_all_button)
350 | self.clear_selection_layout.addStretch()
351 | ### self.clear_selection_layout.addWidget(self.debug_button) #DEBUG ONLY
352 | self.settings_button = self.create_button("", self.open_settings, icon="preferences-system", size=(40, 40))
353 | self.id_selection_layout = QHBoxLayout()
354 | self.id_selection_layout.addWidget(self.settings_button)
355 | self.id_selection_layout.addWidget(self.steamid_combo)
356 | self.id_selection_layout.addWidget(self.gameid_combo)
357 | self.id_selection_layout.addWidget(self.media_type_combo)
358 | self.main_layout = QVBoxLayout()
359 | self.main_layout.addLayout(self.id_selection_layout)
360 | self.main_layout.addWidget(self.clip_frame)
361 | self.main_layout.addLayout(self.clear_selection_layout)
362 | self.bottom_layout = self.create_bottom_layout()
363 | self.main_layout.addLayout(self.bottom_layout)
364 | self.setLayout(self.main_layout)
365 | self.main_layout.setSizeConstraint(QLayout.SetFixedSize)
366 | self.status_label = QLabel("")
367 | self.status_label.setAlignment(Qt.AlignCenter)
368 | self.main_layout.addWidget(self.progress_bar)
369 |
370 | def create_clip_layout(self):
371 | clip_grid = QGridLayout()
372 | clip_frame = QFrame()
373 | clip_frame.setLayout(clip_grid)
374 | return clip_frame, clip_grid
375 |
376 | def create_bottom_layout(self):
377 | self.convert_button = self.create_button("Convert Clip(s)", self.convert_clip, enabled=False)
378 | self.exit_button = self.create_button("Exit", self.close)
379 | self.prev_button = self.create_button("<< Previous", self.show_previous_clips)
380 | self.next_button = self.create_button("Next >>", self.show_next_clips)
381 | bottom_layout = QHBoxLayout()
382 | bottom_layout.addWidget(self.prev_button)
383 | bottom_layout.addWidget(self.next_button)
384 | bottom_layout.addWidget(self.convert_button)
385 | bottom_layout.addWidget(self.exit_button)
386 | return bottom_layout
387 |
388 | def create_button(self, text, slot, enabled=True, icon=None, size=(240, 40)):
389 | button = QPushButton(text)
390 | button.clicked.connect(slot)
391 | button.setEnabled(enabled)
392 | if icon:
393 | button.setIcon(QIcon.fromTheme(icon))
394 | if size:
395 | button.setFixedSize(*size)
396 | return button
397 |
398 | def is_connected(self):
399 | try:
400 | output = subprocess.run(["ping", "-c", "1", "1.1.1.1"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
401 | return output.returncode == 0
402 | except Exception as e:
403 | print(f"Ping failed: {e}")
404 | return False
405 |
406 | def get_custom_record_path(self, userdata_dir):
407 | localconfig_path = os.path.join(userdata_dir, 'config', 'localconfig.vdf')
408 | if not os.path.exists(localconfig_path):
409 | return None
410 | with open(localconfig_path, 'r', encoding='utf-8', errors='ignore') as f:
411 | lines = f.readlines()
412 | for i, line in enumerate(lines):
413 | line = line.strip()
414 | if '"BackgroundRecordPath"' in line:
415 | parts = line.split('"BackgroundRecordPath"')
416 | if len(parts) > 1:
417 | path_line = parts[1].strip()
418 | path_line = path_line.strip('" ')
419 | if path_line:
420 | return path_line
421 | return None
422 |
423 | def del_invalid_clips(self):
424 | invalid_folders = []
425 | for steamid_entry in os.scandir(self.default_dir):
426 | if steamid_entry.is_dir() and steamid_entry.name.isdigit():
427 | userdata_dir = steamid_entry.path
428 | clips_dirs = []
429 | default_clips = os.path.join(userdata_dir, 'gamerecordings', 'clips')
430 | default_video = os.path.join(userdata_dir, 'gamerecordings', 'video')
431 | if os.path.isdir(default_clips):
432 | clips_dirs.append(default_clips)
433 | if os.path.isdir(default_video):
434 | clips_dirs.append(default_video)
435 | custom_path = self.get_custom_record_path(userdata_dir)
436 | if custom_path:
437 | custom_clips = os.path.join(custom_path, 'clips')
438 | custom_video = os.path.join(custom_path, 'video')
439 | if os.path.isdir(custom_clips):
440 | clips_dirs.append(custom_clips)
441 | if os.path.isdir(custom_video):
442 | clips_dirs.append(custom_video)
443 | for clip_dir in clips_dirs:
444 | for folder_entry in os.scandir(clip_dir):
445 | if folder_entry.is_dir() and "_" in folder_entry.name:
446 | folder_path = folder_entry.path
447 | if not self.find_session_mpd(folder_path):
448 | invalid_folders.append(folder_path)
449 | if invalid_folders:
450 | reply = QMessageBox.question(
451 | self,
452 | "Invalid Clips Found",
453 | f"Found {len(invalid_folders)} invalid clip(s). Delete them?",
454 | QMessageBox.Yes | QMessageBox.No,
455 | QMessageBox.No
456 | )
457 | if reply == QMessageBox.Yes:
458 | success = 0
459 | for folder in invalid_folders:
460 | try:
461 | shutil.rmtree(folder)
462 | log_user_action(f"Deleted invalid clip folder: {folder}")
463 | success += 1
464 | except Exception as e:
465 | self.show_error(f"Failed to delete {folder}: {str(e)}")
466 | self.show_info(f"Deleted {success} invalid clip(s).")
467 | self.populate_steamid_dirs()
468 |
469 | def filter_media_type(self):
470 | selected_media_type = self.media_type_combo.currentText()
471 | selected_steamid = self.steamid_combo.currentText()
472 | if not selected_steamid:
473 | return
474 | userdata_dir = os.path.join(self.default_dir, selected_steamid)
475 | custom_record_path = self.get_custom_record_path(userdata_dir)
476 | clips_dir_default = os.path.join(userdata_dir, 'gamerecordings', 'clips')
477 | video_dir_default = os.path.join(userdata_dir, 'gamerecordings', 'video')
478 | clips_dir_custom = os.path.join(custom_record_path, 'clips') if custom_record_path else None
479 | video_dir_custom = os.path.join(custom_record_path, 'video') if custom_record_path else None
480 | clip_folders = []
481 | video_folders = []
482 | if os.path.isdir(clips_dir_default):
483 | clip_folders.extend(folder.path for folder in os.scandir(clips_dir_default) if folder.is_dir() and "_" in folder.name)
484 | if os.path.isdir(video_dir_default):
485 | video_folders.extend(folder.path for folder in os.scandir(video_dir_default) if folder.is_dir() and "_" in folder.name)
486 | if clips_dir_custom and os.path.isdir(clips_dir_custom):
487 | clip_folders.extend(folder.path for folder in os.scandir(clips_dir_custom) if folder.is_dir() and "_" in folder.name)
488 | if video_dir_custom and os.path.isdir(video_dir_custom):
489 | video_folders.extend(folder.path for folder in os.scandir(video_dir_custom) if folder.is_dir() and "_" in folder.name)
490 | if selected_media_type == "All Clips":
491 | self.clip_folders = clip_folders + video_folders
492 | elif selected_media_type == "Manual Clips":
493 | self.clip_folders = clip_folders
494 | elif selected_media_type == "Background Recordings":
495 | self.clip_folders = video_folders
496 | self.clip_folders = sorted(self.clip_folders, key=lambda x: self.extract_datetime_from_folder_name(x), reverse=True)
497 | self.original_clip_folders = list(self.clip_folders)
498 | self.populate_gameid_combo()
499 | self.display_clips()
500 |
501 | def on_steamid_selected(self):
502 | selected_steamid = self.steamid_combo.currentText()
503 | log_user_action(f"Selected SteamID: {selected_steamid}")
504 | userdata_dir = os.path.join(self.default_dir, selected_steamid)
505 | self.filter_media_type()
506 |
507 | def clear_clip_grid(self):
508 | for i in range(self.clip_grid.count()):
509 | widget = self.clip_grid.itemAt(i).widget()
510 | if widget:
511 | widget.deleteLater()
512 |
513 | def clear_selection(self):
514 | log_user_action("Cleared selection of clips")
515 | self.selected_clips.clear()
516 | for i in range(self.clip_grid.count()):
517 | widget = self.clip_grid.itemAt(i).widget()
518 | if widget and hasattr(widget, 'folder'):
519 | widget.setStyleSheet("border: none;")
520 | self.convert_button.setEnabled(False)
521 | self.clear_selection_button.setEnabled(False)
522 |
523 | def populate_steamid_dirs(self):
524 | if not os.path.isdir(self.default_dir):
525 | self.show_error("Default Steam userdata directory not found.")
526 | return
527 | self.steamid_combo.clear()
528 | steamid_found = False
529 | for entry in os.scandir(self.default_dir):
530 | if entry.is_dir() and entry.name.isdigit():
531 | clips_dir = os.path.join(self.default_dir, entry.name, 'gamerecordings', 'clips')
532 | video_dir = os.path.join(self.default_dir, entry.name, 'gamerecordings', 'video')
533 | if os.path.isdir(clips_dir) or os.path.isdir(video_dir):
534 | self.steamid_combo.addItem(entry.name)
535 | steamid_found = True
536 | if not steamid_found:
537 | QMessageBox.warning(
538 | self,
539 | "No Clips Found",
540 | "Clips folder is empty. Record at least one clip to use SteamClip."
541 | )
542 | sys.exit()
543 | self.update_media_type_combo()
544 |
545 | def update_media_type_combo(self):
546 | selected_steamid = self.steamid_combo.currentText()
547 | if not selected_steamid:
548 | return
549 | userdata_dir = os.path.join(self.default_dir, selected_steamid)
550 | clips_dir = os.path.join(userdata_dir, 'gamerecordings', 'clips')
551 | video_dir = os.path.join(userdata_dir, 'gamerecordings', 'video')
552 | self.media_type_combo.clear()
553 | if os.path.isdir(clips_dir) and os.path.isdir(video_dir):
554 | self.media_type_combo.addItems(["All Clips", "Manual Clips", "Background Recordings"])
555 | elif os.path.isdir(clips_dir):
556 | self.media_type_combo.addItems(["Manual Clips"])
557 | elif os.path.isdir(video_dir):
558 | self.media_type_combo.addItems(["Background Recordings"])
559 | self.media_type_combo.setCurrentIndex(0)
560 |
561 | def extract_datetime_from_folder_name(self, folder_name):
562 | parts = folder_name.split('_')
563 | if len(parts) >= 3:
564 | try:
565 | datetime_str = parts[-2] + parts[-1]
566 | return datetime.strptime(datetime_str, "%Y%m%d%H%M%S")
567 | except ValueError:
568 | pass
569 | return datetime.min
570 |
571 | def populate_gameid_combo(self):
572 | game_ids_in_clips = {folder.split('_')[1] for folder in self.clip_folders}
573 | sorted_game_ids = sorted(game_ids_in_clips)
574 | self.gameid_combo.clear()
575 | self.gameid_combo.addItem("All Games")
576 | for game_id in sorted_game_ids:
577 | self.gameid_combo.addItem(self.get_game_name(game_id), game_id)
578 |
579 | def save_game_ids(self):
580 | with open(self.GAME_IDS_FILE, 'w') as f:
581 | json.dump(self.game_ids, f, indent=4)
582 |
583 | def filter_clips_by_gameid(self):
584 | selected_index = self.gameid_combo.currentIndex()
585 | if selected_index == 0:
586 | log_user_action("Selected All Games")
587 | self.clip_folders = [
588 | folder for folder in self.original_clip_folders
589 | if self.find_session_mpd(folder)
590 | ]
591 | else:
592 | selected_game_id = self.gameid_combo.itemData(selected_index)
593 | if not selected_game_id:
594 | return
595 | game_name = self.get_game_name(selected_game_id)
596 | log_user_action(f"Selected Game: {game_name} (ID: {selected_game_id})")
597 | self.clip_folders = [
598 | folder for folder in self.original_clip_folders
599 | if f'_{selected_game_id}_' in folder and self.find_session_mpd(folder)
600 | ]
601 | self.clip_index = 0
602 | self.display_clips()
603 |
604 | def display_clips(self):
605 | self.clear_clip_grid()
606 | valid_clip_folders = [
607 | folder for folder in self.clip_folders[self.clip_index:]
608 | if self.find_session_mpd(folder)
609 | ]
610 | clips_to_show = valid_clip_folders[:6]
611 | for index, folder in enumerate(clips_to_show):
612 | session_mpd_file = self.find_session_mpd(folder)
613 | thumbnail_path = os.path.join(folder, 'thumbnail.jpg')
614 | if session_mpd_file and not os.path.exists(thumbnail_path):
615 | self.extract_first_frame(session_mpd_file, thumbnail_path)
616 | if os.path.exists(thumbnail_path):
617 | self.add_thumbnail_to_grid(thumbnail_path, folder, index)
618 | placeholders_needed = 6 - len(clips_to_show)
619 | for i in range(placeholders_needed):
620 | placeholder = QFrame()
621 | placeholder.setFixedSize(300, 180)
622 | placeholder.setStyleSheet("border: none; background-color: transparent;")
623 | self.clip_grid.addWidget(placeholder, (len(clips_to_show) + i) // 3, (len(clips_to_show) + i) % 3)
624 | for i in range(self.clip_grid.count()):
625 | widget = self.clip_grid.itemAt(i).widget()
626 | if widget and hasattr(widget, 'folder') and widget.folder in self.selected_clips:
627 | widget.setStyleSheet("border: 3px solid lightblue;")
628 | self.update_navigation_buttons()
629 | self.export_all_button.setEnabled(bool(self.clip_folders))
630 |
631 | def extract_first_frame(self, session_mpd_path, output_thumbnail_path):
632 | ffmpeg_path = iio.get_ffmpeg_exe()
633 | data_dir = os.path.dirname(session_mpd_path)
634 | init_video = os.path.join(data_dir, 'init-stream0.m4s')
635 | chunk_video = glob.glob(os.path.join(data_dir, 'chunk-stream0-*.m4s'))
636 | if not os.path.exists(init_video) or not chunk_video:
637 | logging.error(f"Error extracting thumbnail: {e}")
638 | return
639 | try:
640 | with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp_video:
641 | with open(init_video, 'rb') as f:
642 | tmp_video.write(f.read())
643 | for chunk in sorted(chunk_video):
644 | with open(chunk, 'rb') as f:
645 | tmp_video.write(f.read())
646 | temp_video_path = tmp_video.name
647 | command = [
648 | ffmpeg_path,
649 | '-i', temp_video_path,
650 | '-ss', '00:00:00.000',
651 | '-vframes', '1',
652 | output_thumbnail_path
653 | ]
654 | subprocess.run(command, check=True)
655 | except subprocess.CalledProcessError as e:
656 | logging.error(f"Error extracting thumbnail: {e}")
657 | finally:
658 | if 'temp_video_path' in locals() and os.path.exists(temp_video_path):
659 | os.unlink(temp_video_path)
660 |
661 | def get_clip_duration(self, clip_folder):
662 | total_seconds = 0.0
663 | session_mpd_files = self.find_session_mpd(clip_folder)
664 | for session_mpd_path in session_mpd_files:
665 | try:
666 | import xml.etree.ElementTree as ET
667 | tree = ET.parse(session_mpd_path)
668 | root = tree.getroot()
669 | ns = {'dash': 'urn:mpeg:dash:schema:mpd:2011'}
670 | mpd_element = root
671 | if 'mediaPresentationDuration' in mpd_element.attrib:
672 | duration_str = mpd_element.attrib['mediaPresentationDuration']
673 | duration_str = duration_str[2:]
674 | if 'H' in duration_str:
675 | hours, rest = duration_str.split('H')
676 | minutes, seconds = rest.split('M') if 'M' in rest else (rest[:-1], '0S')
677 | seconds = seconds.split('S')[0]
678 | total_seconds += int(hours) * 3600 + int(minutes) * 60 + float(seconds)
679 | elif 'M' in duration_str:
680 | minutes, seconds = duration_str.split('M')
681 | seconds = seconds.split('S')[0]
682 | total_seconds += int(minutes) * 60 + float(seconds)
683 | else:
684 | total_seconds += float(duration_str.split('S')[0])
685 | else:
686 | logging.warning(f"Attribute 'mediaPresentationDuration' not found in {session_mpd_path}")
687 | except Exception as e:
688 | logging.error(f"Error parsing {session_mpd_path}: {e}")
689 | minutes = int(total_seconds // 60)
690 | seconds = int(total_seconds % 60)
691 | return f"{minutes}:{seconds:02d}"
692 |
693 | def add_thumbnail_to_grid(self, thumbnail_path, folder, index):
694 | container = QFrame()
695 | container.setFixedSize(340, 200)
696 | container_layout = QVBoxLayout()
697 | container.setLayout(container_layout)
698 | pixmap = QPixmap(thumbnail_path).scaled(340, 200, Qt.KeepAspectRatio)
699 | thumbnail_label = QLabel()
700 | thumbnail_label.setPixmap(pixmap)
701 | thumbnail_label.setAlignment(Qt.AlignCenter)
702 | thumbnail_label.setStyleSheet("border: none;")
703 |
704 | def select_clip_event(event):
705 | self.select_clip(folder, container)
706 |
707 | thumbnail_label.mousePressEvent = select_clip_event
708 | container_layout.addWidget(thumbnail_label)
709 |
710 | duration = self.get_clip_duration(folder)
711 | duration_label = QLabel(f"{duration}", container)
712 | duration_label.setStyleSheet("font-size: 14px; color: white; background-color: rgba(0, 0, 0, 180); border-radius: 3px; border: none;")
713 | duration_label.setAlignment(Qt.AlignRight | Qt.AlignBottom)
714 | duration_label.setAttribute(Qt.WA_TransparentForMouseEvents, True)
715 | duration_label.adjustSize()
716 |
717 | duration_width = duration_label.width()
718 | duration_height = duration_label.height()
719 | x = 340 - duration_width - 20
720 | y = 200 - duration_height - 20
721 | duration_label.move(x, y)
722 |
723 | container.folder = folder
724 | self.clip_grid.addWidget(container, index // 3, index % 3)
725 |
726 | def select_clip(self, folder, container):
727 | if folder in self.selected_clips:
728 | log_user_action(f"Deselected clip: {folder}")
729 | self.selected_clips.remove(folder)
730 | container.setStyleSheet("border: none;")
731 | else:
732 | log_user_action(f"Selected clip: {folder}")
733 | self.selected_clips.add(folder)
734 | container.setStyleSheet("border: 3px solid lightblue;")
735 | self.convert_button.setEnabled(bool(self.selected_clips))
736 | self.clear_selection_button.setEnabled(len(self.selected_clips) >= 1)
737 |
738 | def update_navigation_buttons(self):
739 | self.prev_button.setEnabled(self.clip_index > 0)
740 | self.next_button.setEnabled(self.clip_index + 6 < len(self.clip_folders))
741 |
742 | def show_previous_clips(self):
743 | if self.clip_index - 6 >= 0:
744 | log_user_action("Navigated to previous clips")
745 | self.clip_index -= 6
746 | self.display_clips()
747 |
748 | def show_next_clips(self):
749 | if self.clip_index + 6 < len(self.clip_folders):
750 | log_user_action("Navigated to next clips")
751 | self.clip_index += 6
752 | self.display_clips()
753 |
754 | def update_progress(self, current_clip, total_clips, step, total_steps):
755 | clip_segment = 100 / total_clips
756 | step_progress = (step / total_steps) * clip_segment
757 | total_progress = (current_clip * clip_segment) + step_progress
758 | self.progress_bar.setValue(int(total_progress))
759 | QApplication.processEvents()
760 |
761 | def process_clips(self, selected_clips=None, export_all=False):
762 | if self.export_dir is None or not os.path.isdir(self.export_dir):
763 | logging.warning(f"Export directory '{self.export_dir}' not found.")
764 | reply = QMessageBox.critical(
765 | self,
766 | "!WARNING!",
767 | f"Directory '{self.export_dir}' not found.\n"
768 | "Use Desktop as export directory?",
769 | QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
770 | QMessageBox.StandardButton.Yes
771 | )
772 | if reply == QMessageBox.StandardButton.Yes:
773 | self.export_dir = os.path.expanduser("~/Desktop")
774 | self.save_config(self.default_dir, self.export_dir)
775 | QMessageBox.information(self, "Info", f"Export path set to: {self.export_dir}")
776 | else:
777 | QMessageBox.warning(self, "Operation Cancelled", "Export operation has been cancelled.")
778 | return
779 | QApplication.processEvents()
780 |
781 | if export_all:
782 | selected_game_index = self.gameid_combo.currentIndex()
783 | selected_media_type = self.media_type_combo.currentText()
784 | filtered_clips = self.original_clip_folders.copy()
785 | if selected_media_type == "Manual Clips":
786 | filtered_clips = [c for c in filtered_clips if "clips" in c]
787 | elif selected_media_type == "Background Recordings":
788 | filtered_clips = [c for c in filtered_clips if "video" in c]
789 | if selected_game_index > 0:
790 | game_id = self.gameid_combo.itemData(selected_game_index)
791 | filtered_clips = [c for c in filtered_clips if f"_{game_id}_" in c]
792 | clip_list = filtered_clips
793 | else:
794 | clip_list = list(selected_clips) if selected_clips else []
795 |
796 | if not clip_list:
797 | self.show_error("No clips to process")
798 | return
799 |
800 | output_dir = self.export_dir or os.path.expanduser("~/Desktop")
801 | ffmpeg_path = iio.get_ffmpeg_exe()
802 | errors = False
803 |
804 | total_clips = len(clip_list)
805 | self.progress_bar.setVisible(True)
806 | self.progress_bar.setRange(0, 100)
807 |
808 | try:
809 | for clip_idx, clip_folder in enumerate(clip_list):
810 | try:
811 | session_mpd_files = []
812 | for root, _, files in os.walk(clip_folder):
813 | if 'session.mpd' in files:
814 | session_mpd_files.append(os.path.join(root, 'session.mpd'))
815 |
816 | if not session_mpd_files:
817 | raise FileNotFoundError("No session.mpd files found")
818 | temp_video_paths = []
819 | temp_audio_paths = []
820 |
821 | for session_mpd in session_mpd_files:
822 | data_dir = os.path.dirname(session_mpd)
823 | init_video = os.path.join(data_dir, 'init-stream0.m4s')
824 | init_audio = os.path.join(data_dir, 'init-stream1.m4s')
825 |
826 | if not (os.path.exists(init_video) and os.path.exists(init_audio)):
827 | raise FileNotFoundError("Initialization files missing")
828 |
829 | with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp_video:
830 | with open(init_video, 'rb') as f:
831 | tmp_video.write(f.read())
832 | for chunk in sorted(glob.glob(os.path.join(data_dir, 'chunk-stream0-*.m4s'))):
833 | with open(chunk, 'rb') as f:
834 | tmp_video.write(f.read())
835 | temp_video_paths.append(tmp_video.name)
836 |
837 | with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp_audio:
838 | with open(init_audio, 'rb') as f:
839 | tmp_audio.write(f.read())
840 | for chunk in sorted(glob.glob(os.path.join(data_dir, 'chunk-stream1-*.m4s'))):
841 | with open(chunk, 'rb') as f:
842 | tmp_audio.write(f.read())
843 | temp_audio_paths.append(tmp_audio.name)
844 |
845 | concatenated_video = os.path.join(tempfile.gettempdir(), f"concat_video_{hash(clip_folder)}.mp4")
846 | concatenated_audio = os.path.join(tempfile.gettempdir(), f"concat_audio_{hash(clip_folder)}.mp4")
847 | video_list_file = os.path.join(tempfile.gettempdir(), f"video_list_{hash(clip_folder)}.txt")
848 | audio_list_file = os.path.join(tempfile.gettempdir(), f"audio_list_{hash(clip_folder)}.txt")
849 |
850 | with open(video_list_file, 'w') as f:
851 | for temp_video in temp_video_paths:
852 | f.write(f"file '{temp_video}'\n")
853 |
854 | with open(audio_list_file, 'w') as f:
855 | for temp_audio in temp_audio_paths:
856 | f.write(f"file '{temp_audio}'\n")
857 |
858 | subprocess.run([
859 | ffmpeg_path,
860 | '-f', 'concat',
861 | '-safe', '0',
862 | '-i', video_list_file,
863 | '-c', 'copy',
864 | concatenated_video
865 | ], check=True)
866 | self.update_progress(clip_idx, total_clips, 1, 3)
867 |
868 | subprocess.run([
869 | ffmpeg_path,
870 | '-f', 'concat',
871 | '-safe', '0',
872 | '-i', audio_list_file,
873 | '-c', 'copy',
874 | concatenated_audio
875 | ], check=True)
876 | self.update_progress(clip_idx, total_clips, 2, 3)
877 |
878 | game_id = os.path.basename(clip_folder).split('_')[1]
879 | game_name = self.get_game_name(game_id) or "Clip"
880 | output_file = self.get_unique_filename(output_dir, f"{game_name}.mp4")
881 |
882 | subprocess.run([
883 | ffmpeg_path,
884 | '-i', concatenated_video,
885 | '-i', concatenated_audio,
886 | '-c', 'copy',
887 | output_file
888 | ], check=True)
889 | self.update_progress(clip_idx, total_clips, 3, 3)
890 |
891 | except Exception as e:
892 | errors = True
893 | logging.error(f"Error processing {clip_folder}: {str(e)}")
894 | finally:
895 | for temp_video in temp_video_paths + temp_audio_paths:
896 | try:
897 | if os.path.exists(temp_video):
898 | os.unlink(temp_video)
899 | except Exception as e:
900 | logging.warning(f"Error cleaning up temp files: {str(e)}")
901 | try:
902 | if 'concatenated_video' in locals() and os.path.exists(concatenated_video):
903 | os.unlink(concatenated_video)
904 | if 'concatenated_audio' in locals() and os.path.exists(concatenated_audio):
905 | os.unlink(concatenated_audio)
906 | if 'video_list_file' in locals() and os.path.exists(video_list_file):
907 | os.unlink(video_list_file)
908 | if 'audio_list_file' in locals() and os.path.exists(audio_list_file):
909 | os.unlink(audio_list_file)
910 | except Exception as e:
911 | logging.warning(f"Error cleaning up concatenated files: {str(e)}")
912 |
913 | if export_all:
914 | msg = "All clips converted successfully" if not errors else "Some clips failed"
915 | self.show_info(msg)
916 | else:
917 | self.selected_clips.clear()
918 | self.display_clips()
919 | self.show_info("Selected clips converted successfully")
920 | return not errors
921 | finally:
922 | self.progress_bar.setVisible(False)
923 |
924 | # Thanks to User /u/vanokhin for the suggestions!
925 |
926 | def convert_clip(self):
927 | QApplication.processEvents()
928 | self.process_clips(selected_clips=self.selected_clips)
929 |
930 | def export_all(self):
931 | QApplication.processEvents()
932 | self.process_clips(export_all=True)
933 |
934 | def find_session_mpd(self, clip_folder):
935 | session_mpd_files = []
936 | for root, _, files in os.walk(clip_folder):
937 | if 'session.mpd' in files:
938 | session_mpd_files.append(os.path.join(root, 'session.mpd'))
939 | return session_mpd_files
940 |
941 | def get_unique_filename(self, directory, filename):
942 | base_name, ext = os.path.splitext(filename)
943 | counter = 1
944 | unique_filename = os.path.join(directory, filename)
945 | while os.path.exists(unique_filename):
946 | unique_filename = os.path.join(directory, f"{base_name}_{counter}{ext}")
947 | counter += 1
948 | return unique_filename
949 |
950 | def show_error(self, message):
951 | QMessageBox.critical(self, "Error", message)
952 |
953 | def show_info(self, message):
954 | QMessageBox.information(self, "Info", message)
955 |
956 | def open_settings(self):
957 | self.settings_window = SettingsWindow(self)
958 | self.settings_window.exec_()
959 |
960 | def debug_crash(self):
961 | log_user_action("Debug button pressed - Simulating crash")
962 | raise Exception("Test crash")
963 |
964 |
965 | class SteamVersionSelectionDialog(QDialog):
966 | def __init__(self, parent):
967 | super().__init__(parent)
968 | self.setWindowTitle("Select Steam Version")
969 | self.setFixedSize(350, 150)
970 | layout = QVBoxLayout()
971 | self.standard_button = QPushButton("Standard")
972 | self.flatpak_button = QPushButton("Flatpak")
973 | self.manual_button = QPushButton("Select the userdata folder manually")
974 | self.standard_button.clicked.connect(lambda: self.accept_and_set("Standard"))
975 | self.flatpak_button.clicked.connect(lambda: self.accept_and_set("Flatpak"))
976 | self.manual_button.clicked.connect(self.select_userdata_folder)
977 | layout.addWidget(QLabel("What version of Steam are you using?"))
978 | layout.addWidget(self.standard_button)
979 | layout.addWidget(self.flatpak_button)
980 | layout.addWidget(self.manual_button)
981 | self.setLayout(layout)
982 | self.selected_version = None
983 |
984 | def accept_and_set(self, version):
985 | self.selected_version = version
986 | self.accept()
987 |
988 | def select_userdata_folder(self):
989 | userdata_path = QFileDialog.getExistingDirectory(self, "Select userdata folder")
990 | if userdata_path:
991 | if self.is_valid_userdata_folder(userdata_path):
992 | self.selected_version = userdata_path
993 | self.accept()
994 | else:
995 | QMessageBox.warning(self, "Invalid Directory", "The selected directory is not a valid userdata folder.")
996 |
997 | def is_valid_userdata_folder(self, folder):
998 | if not os.path.basename(folder) == "userdata":
999 | return False
1000 | steam_id_dirs = [d for d in os.listdir(folder) if os.path.isdir(os.path.join(folder, d)) and d.isdigit()]
1001 | if not steam_id_dirs:
1002 | return False
1003 | for steam_id in steam_id_dirs:
1004 | clips_path = os.path.join(folder, steam_id, 'gamerecordings')
1005 | if os.path.isdir(clips_path):
1006 | return True
1007 | return False
1008 |
1009 | def get_selected_option(self):
1010 | return self.selected_version
1011 |
1012 |
1013 | class SettingsWindow(QDialog):
1014 | def __init__(self, parent):
1015 | super().__init__(parent)
1016 | self.setWindowTitle("Settings")
1017 | self.setFixedSize(220, 400)
1018 | layout = QVBoxLayout()
1019 | self.open_config_button = self.create_button("Open Config Folder", self.open_config_folder, "folder-open")
1020 | self.edit_game_ids_button = self.create_button("Edit Game Name", self.open_edit_game_ids, "edit-rename")
1021 | self.update_game_ids_button = self.create_button("Update GameIDs", self.update_game_ids, "view-refresh")
1022 | self.check_for_updates_button = self.create_button("Check for Updates", self.check_for_updates_in_settings, "view-refresh")
1023 | self.close_settings_button = self.create_button("Close Settings", self.close, "window-close")
1024 | self.select_export_button = self.create_button("Set Export Path", self.select_export_path, "folder-open")
1025 | self.delete_config_button = self.create_button("Delete Config Folder", self.delete_config_folder, "edit-delete")
1026 | self.version_label = QLabel(f"Version: {parent.CURRENT_VERSION}")
1027 | self.version_label.setAlignment(Qt.AlignLeft)
1028 | self.setLayout(layout)
1029 | layout.addWidget(self.open_config_button)
1030 | layout.addWidget(self.select_export_button)
1031 | layout.addWidget(self.edit_game_ids_button)
1032 | layout.addWidget(self.update_game_ids_button)
1033 | layout.addWidget(self.check_for_updates_button)
1034 | layout.addWidget(self.delete_config_button)
1035 | layout.addWidget(self.close_settings_button)
1036 | layout.addWidget(self.version_label)
1037 |
1038 | def select_export_path(self):
1039 | export_path = QFileDialog.getExistingDirectory(self, "Set Export Folder")
1040 | if export_path and os.path.isdir(export_path):
1041 | try:
1042 | test_file = os.path.join(export_path, ".test_write_permission")
1043 | with open(test_file, 'w') as f:
1044 | f.write("test")
1045 | os.remove(test_file)
1046 | self.parent().export_dir = export_path
1047 | self.parent().save_config(self.parent().default_dir, self.parent().export_dir)
1048 | QMessageBox.information(self, "Info", f"Export path set to: {export_path}")
1049 | return
1050 | except Exception as e:
1051 | QMessageBox.warning(self, "Invalid Directory",
1052 | f"The selected directory is not writable: {str(e)}")
1053 | default_export_path = os.path.expanduser("~/Desktop")
1054 | self.parent().export_dir = default_export_path
1055 | self.parent().save_config(self.parent().default_dir, default_export_path)
1056 | QMessageBox.warning(self, "Invalid Directory",
1057 | f"Selected export directory is invalid. Using default: {default_export_path}")
1058 |
1059 | def create_button(self, text, slot, icon=None, size=(200, 45)):
1060 | button = QPushButton(text)
1061 | button.clicked.connect(slot)
1062 | if icon:
1063 | button.setIcon(QIcon.fromTheme(icon))
1064 | if size:
1065 | button.setFixedSize(*size)
1066 | return button
1067 |
1068 | def check_for_updates_in_settings(self):
1069 | release_info = self.parent().perform_update_check(show_message=False)
1070 | if release_info is None:
1071 | QMessageBox.critical(self, "Error", "Failed to fetch the latest release information.")
1072 | return
1073 | if release_info['version'] == self.parent().CURRENT_VERSION:
1074 | QMessageBox.information(self, "No Updates Available", "You are already using the latest version of SteamClip.")
1075 | else:
1076 | self.parent().prompt_update(release_info['version'], release_info['changelog'])
1077 |
1078 | def open_edit_game_ids(self):
1079 | edit_window = EditGameIDWindow(self.parent())
1080 | edit_window.exec_()
1081 |
1082 | def open_config_folder(self):
1083 | config_folder = SteamClipApp.CONFIG_DIR
1084 | if sys.platform.startswith('linux'):
1085 | subprocess.run(['xdg-open', config_folder])
1086 | elif sys.platform == 'darwin':
1087 | subprocess.run(['open', config_folder])
1088 | elif sys.platform == 'win32':
1089 | subprocess.run(['explorer', config_folder])
1090 |
1091 | def update_game_ids(self):
1092 | if not self.parent().is_connected():
1093 | return QMessageBox.warning(self, "Warning", "No internet connection")
1094 | game_ids = {folder.split('_')[1] for folder in self.parent().original_clip_folders}
1095 | for game_id in game_ids:
1096 | if game_id not in self.parent().game_ids:
1097 | name = self.parent().fetch_game_name_from_steam(game_id)
1098 | if name:
1099 | self.parent().game_ids[game_id] = name
1100 | self.parent().save_game_ids()
1101 | self.parent().populate_gameid_combo()
1102 | QMessageBox.information(self, "Success", "Game ID database updated")
1103 |
1104 | def delete_config_folder(self):
1105 | reply = QMessageBox.question(
1106 | self,
1107 | "Confirm Deletion",
1108 | f"Are you sure you want to delete the entire configuration folder?\n\n{SteamClipApp.CONFIG_DIR}\n\nThis action cannot be undone.",
1109 | QMessageBox.Yes | QMessageBox.No,
1110 | QMessageBox.No
1111 | )
1112 | if reply == QMessageBox.Yes:
1113 | try:
1114 | shutil.rmtree(SteamClipApp.CONFIG_DIR)
1115 | QMessageBox.information(self, "Deletion Complete", "Configuration folder has been deleted.\nThe application will now close.")
1116 | QApplication.quit()
1117 | except Exception as e:
1118 | QMessageBox.critical(self, "Error", f"Failed to delete configuration folder:\n{str(e)}")
1119 |
1120 | class EditGameIDWindow(QDialog):
1121 | def __init__(self, parent):
1122 | super().__init__(parent)
1123 | self.setWindowTitle("Edit Game Names")
1124 | self.setFixedSize(400, 300)
1125 | self.layout = QVBoxLayout()
1126 | self.table_widget = QTableWidget()
1127 | self.populate_table()
1128 | self.layout.addWidget(self.table_widget)
1129 | self.layout.addLayout(self.create_button_layout())
1130 | self.setLayout(self.layout)
1131 |
1132 | def populate_table(self):
1133 | self.game_names = {
1134 | self.parent().gameid_combo.itemData(i): self.parent().gameid_combo.itemText(i)
1135 | for i in range(1, self.parent().gameid_combo.count())
1136 | }
1137 | self.table_widget.setRowCount(len(self.game_names))
1138 | self.table_widget.setColumnCount(1)
1139 | self.table_widget.setHorizontalHeaderLabels(["Game Name"])
1140 | for row, (game_id, game_name) in enumerate(self.game_names.items()):
1141 | name_item = QTableWidgetItem(game_name)
1142 | name_item.setData(Qt.UserRole, game_id)
1143 | self.table_widget.setItem(row, 0, name_item)
1144 | self.table_widget.horizontalHeader().setStretchLastSection(True)
1145 |
1146 | def create_button_layout(self):
1147 | button_layout = QHBoxLayout()
1148 | button_layout.addWidget(self.create_button("Cancel", self.reject))
1149 | button_layout.addWidget(self.create_button("Apply Changes", self.save_changes))
1150 | return button_layout
1151 |
1152 | def create_button(self, text, slot):
1153 | button = QPushButton(text)
1154 | button.clicked.connect(slot)
1155 | return button
1156 |
1157 | def save_changes(self):
1158 | updated_game_names = {}
1159 | for row in range(self.table_widget.rowCount()):
1160 | item = self.table_widget.item(row, 0)
1161 | if item:
1162 | game_id = item.data(Qt.UserRole)
1163 | new_name = item.text()
1164 | updated_game_names[game_id] = new_name
1165 | game_ids_file = os.path.join(SteamClipApp.CONFIG_DIR, 'GameIDs.json')
1166 | with open(game_ids_file, 'w') as f:
1167 | json.dump(updated_game_names, f, indent=4)
1168 | QMessageBox.information(self, "Info", "Game names saved successfully.")
1169 | self.parent().load_game_ids()
1170 | self.parent().populate_gameid_combo()
1171 |
1172 |
1173 | if __name__ == "__main__":
1174 | sys.excepthook = handle_exception
1175 | app = QApplication(sys.argv)
1176 | app.setStyleSheet("""
1177 | QWidget {
1178 | font-size: 16px;
1179 | }
1180 | QLabel {
1181 | font-size: 18px;
1182 | }
1183 | QPushButton {
1184 | font-size: 16px;
1185 | }
1186 | QComboBox {
1187 | font-size: 16px;
1188 | combobox-popup: 0;
1189 | }
1190 | QTableWidget {
1191 | font-size: 16px;
1192 | }
1193 | """)
1194 | try:
1195 | window = SteamClipApp()
1196 | window.show()
1197 | sys.exit(app.exec_())
1198 | except Exception as e:
1199 | handle_exception(type(e), e, e.__traceback__)
1200 |
--------------------------------------------------------------------------------
/steamclipWINDOWS.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import os
3 | import sys
4 | import subprocess
5 | import json
6 | import imageio_ffmpeg as iio
7 | import logging
8 | import traceback
9 | import shutil
10 | import tempfile
11 | import glob
12 | import requests
13 | import time
14 | import xml.etree.ElementTree as ET
15 | from PyQt5.QtWidgets import (
16 | QApplication, QWidget, QVBoxLayout, QHBoxLayout,
17 | QPushButton, QLabel, QGridLayout,
18 | QFrame, QComboBox, QDialog, QTableWidget,
19 | QTableWidgetItem, QHeaderView, QTextEdit,
20 | QMessageBox, QFileDialog, QLayout, QProgressBar
21 | )
22 | from PyQt5.QtGui import QPixmap, QIcon
23 | from PyQt5.QtCore import Qt
24 | from datetime import datetime
25 |
26 | user_actions = []
27 |
28 | def setup_logging():
29 | log_dir = os.path.join(SteamClipApp.CONFIG_DIR, 'logs')
30 | os.makedirs(log_dir, exist_ok=True)
31 | timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
32 | log_file = os.path.join(log_dir, f"{timestamp}.log")
33 | logging.basicConfig(
34 | filename=log_file,
35 | level=logging.INFO,
36 | format='%(asctime)s %(levelname)s: %(message)s'
37 | )
38 |
39 | def log_user_action(action):
40 | user_actions.append(action)
41 | logging.info(f"User Action: {action}")
42 |
43 | def handle_exception(exc_type, exc_value, exc_traceback):
44 | if issubclass(exc_type, KeyboardInterrupt):
45 | sys.__excepthook__(exc_type, exc_value, exc_traceback)
46 | return
47 | log_dir = os.path.join(SteamClipApp.CONFIG_DIR, 'logs')
48 | os.makedirs(log_dir, exist_ok=True)
49 | timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
50 | log_file = os.path.join(log_dir, f"crash_{timestamp}.log")
51 | with open(log_file, "w") as f:
52 | f.write("User Actions:\n")
53 | for action in user_actions:
54 | f.write(f"- {action}\n")
55 | f.write("\nError Details:\n")
56 | traceback.print_exception(exc_type, exc_value, exc_traceback, file=f)
57 | error_message = f"An unexpected error occurred:\n{exc_value}"
58 | QMessageBox.critical(None, "Critical Error", error_message)
59 |
60 | class SteamClipApp(QWidget):
61 | CONFIG_DIR = os.path.join(
62 | os.environ.get('LOCALAPPDATA', os.path.expanduser("~")),
63 | 'SteamClip'
64 | )
65 | CONFIG_FILE = os.path.join(CONFIG_DIR, 'SteamClip.conf')
66 | GAME_IDS_FILE = os.path.join(CONFIG_DIR, 'GameIDs.json')
67 | STEAM_APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
68 | CURRENT_VERSION = "v2.16.4"
69 |
70 | def __init__(self):
71 | super().__init__()
72 | self.cleanup_temp_files()
73 | log_user_action("Application started")
74 | self.setWindowTitle("SteamClip")
75 | self.setGeometry(100, 100, 900, 600)
76 | self.clip_index = 0
77 | self.clip_folders = []
78 | self.original_clip_folders = []
79 | self.game_ids = {}
80 | self.config = self.load_config()
81 | self.default_dir = self.config.get('userdata_path')
82 | self.export_dir = self.config.get('export_path', os.path.expanduser("~/Desktop"))
83 | first_run = not os.path.exists(self.CONFIG_FILE)
84 |
85 | if not self.default_dir:
86 | self.default_dir = self.prompt_steam_version_selection()
87 | if not self.default_dir:
88 | QMessageBox.critical(self, "Critical Error", "Failed to locate Steam userdata directory. Exiting.")
89 | sys.exit(1)
90 |
91 | self.save_config(self.default_dir, self.export_dir)
92 | self.load_game_ids()
93 | self.selected_clips = set()
94 | self.setup_ui()
95 | self.del_invalid_clips()
96 | self.populate_steamid_dirs()
97 | self.perform_update_check()
98 |
99 | if first_run:
100 | QMessageBox.information(self, "INFO",
101 | "Clips will be saved on the Desktop. You can change the export path in the settings")
102 |
103 | def cleanup_temp_files(self):
104 | temp_files = glob.glob(os.path.join(self.CONFIG_DIR, "steamclip_new*"))
105 | for temp_file in temp_files:
106 | try:
107 | if os.path.exists(temp_file):
108 | os.remove(temp_file)
109 | logging.info(f"Cleaned up temp file: {temp_file}")
110 | except Exception as e:
111 | logging.error(f"Error cleaning temp file {temp_file}: {str(e)}")
112 |
113 | def load_config(self):
114 | config = {'userdata_path': None, 'export_path': os.path.expanduser("~/Desktop")}
115 | if os.path.exists(self.CONFIG_FILE):
116 | with open(self.CONFIG_FILE, 'r') as f:
117 | lines = f.readlines()
118 | for line in lines:
119 | line = line.strip()
120 | if not line or line.startswith('#'):
121 | continue
122 | if '=' in line:
123 | key, value = line.split('=', 1)
124 | key = key.strip()
125 | value = value.strip()
126 | if key == 'userdata_path':
127 | config['userdata_path'] = value
128 | elif key == 'export_path':
129 | config['export_path'] = value
130 | else:
131 | logging.warning(f"Malformed config line (missing '='): {line}")
132 | return config
133 |
134 | def save_config(self, userdata_path=None, export_path=None):
135 | config = {}
136 | if userdata_path:
137 | config['userdata_path'] = userdata_path
138 | config['export_path'] = export_path or os.path.expanduser("~/Desktop")
139 | with open(self.CONFIG_FILE, 'w') as f:
140 | for key, value in config.items():
141 | f.write(f"{key}={value}\n")
142 |
143 | def moveEvent(self, event):
144 | super().moveEvent(event)
145 | for combo_box in [self.steamid_combo, self.gameid_combo, self.media_type_combo]:
146 | if combo_box.view().isVisible():
147 | combo_box.hidePopup()
148 |
149 | def perform_update_check(self, show_message=True):
150 | release_info = self.get_latest_release_from_github()
151 | if not release_info:
152 | return None
153 | latest_version = release_info['version']
154 | if latest_version != self.CURRENT_VERSION and show_message:
155 | self.prompt_update(latest_version, release_info['changelog'])
156 | return release_info
157 |
158 | def download_update(self, latest_release):
159 | self.wait_message = QDialog(self)
160 | self.wait_message.setWindowTitle("Updating SteamClip")
161 | self.wait_message.setFixedSize(400, 120)
162 | layout = QVBoxLayout()
163 | layout.setAlignment(Qt.AlignCenter)
164 | self.progress_label = QLabel("Downloading update... 0.0%")
165 | self.progress_label.setAlignment(Qt.AlignCenter)
166 | layout.addWidget(self.progress_label)
167 | progress_frame = QFrame()
168 | progress_frame.setFixedSize(300, 30)
169 | progress_frame.setStyleSheet("background-color: #e0e0e0; border-radius: 5px;")
170 | self.progress_inner = QFrame(progress_frame)
171 | self.progress_inner.setGeometry(0, 0, 0, 30)
172 | self.progress_inner.setStyleSheet("background-color: #4caf50; border-radius: 5px;")
173 | layout.addWidget(progress_frame)
174 | cancel_button = QPushButton("Cancel Download")
175 | cancel_button.clicked.connect(lambda: self.cancel_download(temp_download_path))
176 | layout.addWidget(cancel_button)
177 | self.wait_message.setLayout(layout)
178 | self.wait_message.show()
179 | self._is_cancelled = False
180 | download_url = f"https://github.com/Nastas95/SteamClip/releases/download/{latest_release}/steamclip.exe"
181 | temp_download_path = os.path.join(self.CONFIG_DIR, "steamclip_new.exe")
182 | current_executable = os.path.abspath(sys.argv[0])
183 | try:
184 | import requests
185 | response = requests.get(download_url, stream=True)
186 | response.raise_for_status()
187 | total_size = int(response.headers.get('content-length', 0))
188 | downloaded_size = 0
189 | with open(temp_download_path, 'wb') as f:
190 | for chunk in response.iter_content(chunk_size=8192):
191 | if self._is_cancelled:
192 | break
193 | if chunk:
194 | f.write(chunk)
195 | downloaded_size += len(chunk)
196 | if total_size > 0:
197 | percentage = (downloaded_size / total_size) * 100
198 | self.progress_label.setText(f"Downloading update... {percentage:.1f}%")
199 | progress_width = int(300 * (percentage / 100))
200 | self.progress_inner.setFixedWidth(progress_width)
201 | QApplication.processEvents()
202 | if self.wait_message.isHidden():
203 | self.cancel_download(temp_download_path)
204 | return
205 | batch_script = os.path.join(self.CONFIG_DIR, "update.bat")
206 | with open(batch_script, "w") as bat:
207 | bat.write(f'''
208 | @echo off
209 | setlocal
210 | set "old_exe={current_executable}"
211 | set "new_exe={temp_download_path}"
212 |
213 | :: 1. Wait
214 | :loop
215 | tasklist | findstr /C:"{os.path.basename(current_executable)}" >nul 2>&1
216 | if %ERRORLEVEL% == 0 (
217 | timeout /t 1
218 | goto loop
219 | )
220 |
221 | :: 2. Replace
222 | move /Y "%new_exe%" "%old_exe%" >nul 2>&1
223 | if %ERRORLEVEL% NEQ 0 (
224 | echo Failed to replace executable. Retrying...
225 | timeout /t 2
226 | goto loop
227 | )
228 |
229 | :: 3. Run
230 | :: Usa 'cmd /c start' per evitare l'ereditarietà della directory temporanea
231 | start "" /D "%~dp0" "%old_exe%"
232 |
233 | :: 4. Delete
234 | del "%~f0%"
235 | ''')
236 | subprocess.Popen([batch_script], shell=True)
237 | sys.exit(0)
238 | except Exception as e:
239 | self.wait_message.close()
240 | QMessageBox.critical(self, "Update Failed", f"Failed to update SteamClip: {e}")
241 |
242 | def cancel_download(self, temp_download_path):
243 | self._is_cancelled = True
244 | self.wait_message.close()
245 | if hasattr(self, '_response'):
246 | self._response.close()
247 | QMessageBox.information(self, "Download Cancelled", "The update has been cancelled.")
248 | self._is_cancelled = False
249 |
250 | def get_latest_release_from_github(self):
251 | url = "https://api.github.com/repos/Nastas95/SteamClip/releases/latest"
252 | try:
253 | import requests
254 | response = requests.get(url)
255 | response.raise_for_status()
256 | release_data = response.json()
257 | return {
258 | 'version': release_data['tag_name'],
259 | 'changelog': release_data.get('body', 'No changelog available')
260 | }
261 | except Exception as e:
262 | logging.error(f"Error fetching release info: {e}")
263 | return None
264 |
265 | def prompt_update(self, latest_version, changelog):
266 | message_box = QMessageBox(QMessageBox.Question, "Update Available",
267 | f"A new update ({latest_version}) is available. Update now?")
268 | update_button = message_box.addButton("Update", QMessageBox.AcceptRole)
269 | changelog_button = message_box.addButton("View Changelog", QMessageBox.ActionRole)
270 | cancel_button = message_box.addButton("Cancel", QMessageBox.RejectRole)
271 | message_box.exec_()
272 | if message_box.clickedButton() == update_button:
273 | self.download_update(latest_version)
274 | elif message_box.clickedButton() == changelog_button:
275 | self.show_changelog(latest_version, changelog)
276 |
277 | def show_changelog(self, latest_version, changelog_text):
278 | dialog = QDialog(self)
279 | dialog.setWindowTitle(f"Changelog - {latest_version}")
280 | dialog.setGeometry(100, 100, 600, 400)
281 | layout = QVBoxLayout()
282 | text_edit = QTextEdit()
283 | text_edit.setReadOnly(True)
284 | text_edit.setMarkdown(changelog_text)
285 | button_layout = QHBoxLayout()
286 | update_button = QPushButton("Update Now")
287 | update_button.clicked.connect(lambda: (dialog.close(), self.download_update(latest_version)))
288 | close_button = QPushButton("Close")
289 | close_button.clicked.connect(dialog.close)
290 | button_layout.addWidget(update_button)
291 | button_layout.addWidget(close_button)
292 | layout.addWidget(text_edit)
293 | layout.addLayout(button_layout)
294 | dialog.setLayout(layout)
295 | dialog.exec_()
296 |
297 | def check_and_load_userdata_folder(self):
298 | if not os.path.exists(self.CONFIG_FILE):
299 | return self.prompt_steam_version_selection()
300 | with open(self.CONFIG_FILE, 'r') as f:
301 | userdata_path = f.read().strip()
302 | return userdata_path if os.path.isdir(userdata_path) else self.prompt_steam_version_selection()
303 |
304 | def prompt_steam_version_selection(self):
305 | dialog = SteamVersionSelectionDialog(self)
306 | while dialog.exec_() == QDialog.Accepted:
307 | selected_option = dialog.get_selected_option()
308 | if selected_option == "Standard":
309 | userdata_path = os.path.expanduser("C:/Program Files (x86)/Steam/userdata")
310 | #elif selected_option == "Flatpak":
311 | # userdata_path = os.path.expanduser("~/.var/app/com.valvesoftware.Steam/data/Steam/userdata")
312 | elif os.path.isdir(selected_option):
313 | userdata_path = selected_option
314 | else:
315 | continue
316 | if os.path.isdir(userdata_path):
317 | self.save_default_directory(userdata_path)
318 | return userdata_path
319 | else:
320 | QMessageBox.warning(self, "Invalid Directory", "The selected directory is not valid. Please select again.")
321 | return None
322 |
323 | def save_default_directory(self, directory):
324 | os.makedirs(self.CONFIG_DIR, exist_ok=True)
325 | with open(self.CONFIG_FILE, 'w') as f:
326 | f.write(directory)
327 |
328 | def load_game_ids(self):
329 | if not os.path.exists(self.GAME_IDS_FILE):
330 | QMessageBox.information(self, "Info", "SteamClip will now try to download the GameID database. Please, be patient.")
331 | self.game_ids = {}
332 | else:
333 | with open(self.GAME_IDS_FILE, 'r') as f:
334 | self.game_ids = json.load(f)
335 |
336 | def fetch_game_name_from_steam(self, game_id):
337 | url = f"{self.STEAM_APP_DETAILS_URL}?appids={game_id}&filters=basic"
338 | try:
339 | response = requests.get(url)
340 | response.raise_for_status() # Controlla errori HTTP
341 | data = response.json()
342 | if str(game_id) in data and data[str(game_id)]['success']:
343 | return data[str(game_id)]['data']['name']
344 | except Exception as e:
345 | logging.error(f"Error fetching game name for {game_id}: {e}")
346 | return f"{game_id}"
347 |
348 | def get_game_name(self, game_id):
349 | if game_id in self.game_ids:
350 | return self.game_ids[game_id]
351 | if not game_id.isdigit():
352 | default_name = f"{game_id}"
353 | self.game_ids[game_id] = default_name
354 | self.save_game_ids()
355 | return default_name
356 | name = self.fetch_game_name_from_steam(game_id)
357 | if name:
358 | self.game_ids[game_id] = name
359 | self.save_game_ids()
360 | return name
361 | default_name = f"{game_id}"
362 | self.game_ids[game_id] = default_name
363 | self.save_game_ids()
364 | return default_name
365 |
366 | def setup_ui(self):
367 | self.setStyleSheet("QComboBox { combobox-popup: 0; }")
368 | self.steamid_combo = QComboBox()
369 | self.gameid_combo = QComboBox()
370 | self.media_type_combo = QComboBox()
371 | self.steamid_combo.setFixedSize(300, 40)
372 | self.gameid_combo.setFixedSize(300, 40)
373 | self.media_type_combo.setFixedSize(300, 40)
374 | self.media_type_combo.addItems(["All Clips", "Manual Clips", "Background Clips"])
375 | self.media_type_combo.setCurrentIndex(0)
376 | self.steamid_combo.currentIndexChanged.connect(self.on_steamid_selected)
377 | self.gameid_combo.currentIndexChanged.connect(self.filter_clips_by_gameid)
378 | self.media_type_combo.currentIndexChanged.connect(self.filter_media_type)
379 | self.clip_frame, self.clip_grid = self.create_clip_layout()
380 | self.clear_selection_button = self.create_button("Clear Selection", self.clear_selection, enabled=False, size=(150, 40))
381 | self.export_all_button = self.create_button("Export All", self.export_all, enabled=True, size=(150, 40))
382 | self.progress_bar = QProgressBar()
383 | self.progress_bar.setVisible(False)
384 | ### self.debug_button = self.create_button("Debug Crash", self.debug_crash, enabled=True, size=(150, 40)) #DEBUG ONLY
385 | self.clear_selection_layout = QHBoxLayout()
386 | self.clear_selection_layout.addStretch()
387 | self.clear_selection_layout.addWidget(self.clear_selection_button)
388 | self.clear_selection_layout.addWidget(self.export_all_button)
389 | self.clear_selection_layout.addStretch()
390 | ### self.clear_selection_layout.addWidget(self.debug_button) #DEBUG ONLY
391 | self.settings_button = self.create_button("", self.open_settings, icon="preferences-system", size=(40, 40))
392 | self.id_selection_layout = QHBoxLayout()
393 | self.id_selection_layout.addWidget(self.settings_button)
394 | self.id_selection_layout.addWidget(self.steamid_combo)
395 | self.id_selection_layout.addWidget(self.gameid_combo)
396 | self.id_selection_layout.addWidget(self.media_type_combo)
397 | self.main_layout = QVBoxLayout()
398 | self.main_layout.addLayout(self.id_selection_layout)
399 | self.main_layout.addWidget(self.clip_frame)
400 | self.main_layout.addLayout(self.clear_selection_layout)
401 | self.bottom_layout = self.create_bottom_layout()
402 | self.main_layout.addLayout(self.bottom_layout)
403 | self.setLayout(self.main_layout)
404 | self.main_layout.setSizeConstraint(QLayout.SetFixedSize)
405 | self.status_label = QLabel("")
406 | self.status_label.setAlignment(Qt.AlignCenter)
407 | self.main_layout.addWidget(self.progress_bar)
408 |
409 | def create_clip_layout(self):
410 | clip_grid = QGridLayout()
411 | clip_frame = QFrame()
412 | clip_frame.setLayout(clip_grid)
413 | return clip_frame, clip_grid
414 |
415 | def create_bottom_layout(self):
416 | self.convert_button = self.create_button("Convert Clip(s)", self.convert_clip, enabled=False)
417 | self.exit_button = self.create_button("Exit", self.close)
418 | self.prev_button = self.create_button("<< Previous", self.show_previous_clips)
419 | self.next_button = self.create_button("Next >>", self.show_next_clips)
420 | bottom_layout = QHBoxLayout()
421 | bottom_layout.addWidget(self.prev_button)
422 | bottom_layout.addWidget(self.next_button)
423 | bottom_layout.addWidget(self.convert_button)
424 | bottom_layout.addWidget(self.exit_button)
425 | return bottom_layout
426 |
427 | def create_button(self, text, slot, enabled=True, icon=None, size=(240, 40)):
428 | button = QPushButton(text)
429 | button.clicked.connect(slot)
430 | button.setEnabled(enabled)
431 | if icon:
432 | button.setIcon(QIcon.fromTheme(icon))
433 | if size:
434 | button.setFixedSize(*size)
435 | return button
436 |
437 | def is_connected(self):
438 | try:
439 | response = requests.get("http://www.google.com", timeout=5)
440 | return response.status_code == 200
441 | except requests.ConnectionError:
442 | return False
443 | except Exception as e:
444 | print(f"Connection check failed: {e}")
445 | return False
446 |
447 | def get_custom_record_path(self, userdata_dir):
448 | localconfig_path = os.path.join(userdata_dir, 'config', 'localconfig.vdf')
449 | if not os.path.exists(localconfig_path):
450 | return None
451 | with open(localconfig_path, 'r', encoding='utf-8', errors='ignore') as f:
452 | lines = f.readlines()
453 | for i, line in enumerate(lines):
454 | line = line.strip()
455 | if '"BackgroundRecordPath"' in line:
456 | parts = line.split('"BackgroundRecordPath"')
457 | if len(parts) > 1:
458 | path_line = parts[1].strip()
459 | path_line = path_line.strip('" ')
460 | if path_line:
461 | return path_line
462 | return None
463 |
464 | def del_invalid_clips(self):
465 | invalid_folders = []
466 | for steamid_entry in os.scandir(self.default_dir):
467 | if steamid_entry.is_dir() and steamid_entry.name.isdigit():
468 | userdata_dir = steamid_entry.path
469 | clips_dirs = []
470 | default_clips = os.path.join(userdata_dir, 'gamerecordings', 'clips')
471 | default_video = os.path.join(userdata_dir, 'gamerecordings', 'video')
472 | if os.path.isdir(default_clips):
473 | clips_dirs.append(default_clips)
474 | if os.path.isdir(default_video):
475 | clips_dirs.append(default_video)
476 | custom_path = self.get_custom_record_path(userdata_dir)
477 | if custom_path:
478 | custom_clips = os.path.join(custom_path, 'clips')
479 | custom_video = os.path.join(custom_path, 'video')
480 | if os.path.isdir(custom_clips):
481 | clips_dirs.append(custom_clips)
482 | if os.path.isdir(custom_video):
483 | clips_dirs.append(custom_video)
484 | for clip_dir in clips_dirs:
485 | for folder_entry in os.scandir(clip_dir):
486 | if folder_entry.is_dir() and "_" in folder_entry.name:
487 | folder_path = folder_entry.path
488 | if not self.find_session_mpd(folder_path):
489 | invalid_folders.append(folder_path)
490 | if invalid_folders:
491 | reply = QMessageBox.question(
492 | self,
493 | "Invalid Clips Found",
494 | f"Found {len(invalid_folders)} invalid clip(s). Delete them?",
495 | QMessageBox.Yes | QMessageBox.No,
496 | QMessageBox.No
497 | )
498 | if reply == QMessageBox.Yes:
499 | success = 0
500 | for folder in invalid_folders:
501 | try:
502 | shutil.rmtree(folder)
503 | log_user_action(f"Deleted invalid clip folder: {folder}")
504 | success += 1
505 | except Exception as e:
506 | self.show_error(f"Failed to delete {folder}: {str(e)}")
507 | self.show_info(f"Deleted {success} invalid clip(s).")
508 | self.populate_steamid_dirs()
509 |
510 | def filter_media_type(self):
511 | selected_media_type = self.media_type_combo.currentText()
512 | selected_steamid = self.steamid_combo.currentText()
513 | if not selected_steamid:
514 | return
515 | userdata_dir = os.path.join(self.default_dir, selected_steamid)
516 | custom_record_path = self.get_custom_record_path(userdata_dir)
517 | clips_dir_default = os.path.join(userdata_dir, 'gamerecordings', 'clips')
518 | video_dir_default = os.path.join(userdata_dir, 'gamerecordings', 'video')
519 | clips_dir_custom = os.path.join(custom_record_path, 'clips') if custom_record_path else None
520 | video_dir_custom = os.path.join(custom_record_path, 'video') if custom_record_path else None
521 | clip_folders = []
522 | video_folders = []
523 | if os.path.isdir(clips_dir_default):
524 | clip_folders.extend(folder.path for folder in os.scandir(clips_dir_default) if folder.is_dir() and "_" in folder.name)
525 | if os.path.isdir(video_dir_default):
526 | video_folders.extend(folder.path for folder in os.scandir(video_dir_default) if folder.is_dir() and "_" in folder.name)
527 | if clips_dir_custom and os.path.isdir(clips_dir_custom):
528 | clip_folders.extend(folder.path for folder in os.scandir(clips_dir_custom) if folder.is_dir() and "_" in folder.name)
529 | if video_dir_custom and os.path.isdir(video_dir_custom):
530 | video_folders.extend(folder.path for folder in os.scandir(video_dir_custom) if folder.is_dir() and "_" in folder.name)
531 | if selected_media_type == "All Clips":
532 | self.clip_folders = clip_folders + video_folders
533 | elif selected_media_type == "Manual Clips":
534 | self.clip_folders = clip_folders
535 | elif selected_media_type == "Background Recordings":
536 | self.clip_folders = video_folders
537 | self.clip_folders = sorted(self.clip_folders, key=lambda x: self.extract_datetime_from_folder_name(x), reverse=True)
538 | self.original_clip_folders = list(self.clip_folders)
539 | self.populate_gameid_combo()
540 | self.display_clips()
541 |
542 | def on_steamid_selected(self):
543 | selected_steamid = self.steamid_combo.currentText()
544 | log_user_action(f"Selected SteamID: {selected_steamid}")
545 | userdata_dir = os.path.join(self.default_dir, selected_steamid)
546 | self.filter_media_type()
547 |
548 | def clear_clip_grid(self):
549 | for i in range(self.clip_grid.count()):
550 | widget = self.clip_grid.itemAt(i).widget()
551 | if widget:
552 | widget.deleteLater()
553 |
554 | def clear_selection(self):
555 | log_user_action("Cleared selection of clips")
556 | self.selected_clips.clear()
557 | for i in range(self.clip_grid.count()):
558 | widget = self.clip_grid.itemAt(i).widget()
559 | if widget and hasattr(widget, 'folder'):
560 | widget.setStyleSheet("border: none;")
561 | self.convert_button.setEnabled(False)
562 | self.clear_selection_button.setEnabled(False)
563 |
564 | def populate_steamid_dirs(self):
565 | if not os.path.isdir(self.default_dir):
566 | self.show_error("Default Steam userdata directory not found.")
567 | return
568 | self.steamid_combo.clear()
569 | steamid_found = False
570 | for entry in os.scandir(self.default_dir):
571 | if entry.is_dir() and entry.name.isdigit():
572 | clips_dir = os.path.join(self.default_dir, entry.name, 'gamerecordings', 'clips')
573 | video_dir = os.path.join(self.default_dir, entry.name, 'gamerecordings', 'video')
574 | if os.path.isdir(clips_dir) or os.path.isdir(video_dir):
575 | self.steamid_combo.addItem(entry.name)
576 | steamid_found = True
577 | if not steamid_found:
578 | QMessageBox.warning(
579 | self,
580 | "No Clips Found",
581 | "Clips folder is empty. Record at least one clip to use SteamClip."
582 | )
583 | sys.exit()
584 | self.update_media_type_combo()
585 |
586 | def update_media_type_combo(self):
587 | selected_steamid = self.steamid_combo.currentText()
588 | if not selected_steamid:
589 | return
590 | userdata_dir = os.path.join(self.default_dir, selected_steamid)
591 | clips_dir = os.path.join(userdata_dir, 'gamerecordings', 'clips')
592 | video_dir = os.path.join(userdata_dir, 'gamerecordings', 'video')
593 | self.media_type_combo.clear()
594 | if os.path.isdir(clips_dir) and os.path.isdir(video_dir):
595 | self.media_type_combo.addItems(["All Clips", "Manual Clips", "Background Recordings"])
596 | elif os.path.isdir(clips_dir):
597 | self.media_type_combo.addItems(["Manual Clips"])
598 | elif os.path.isdir(video_dir):
599 | self.media_type_combo.addItems(["Background Recordings"])
600 | self.media_type_combo.setCurrentIndex(0)
601 |
602 | def extract_datetime_from_folder_name(self, folder_name):
603 | parts = folder_name.split('_')
604 | if len(parts) >= 3:
605 | try:
606 | datetime_str = parts[-2] + parts[-1]
607 | return datetime.strptime(datetime_str, "%Y%m%d%H%M%S")
608 | except ValueError:
609 | pass
610 | return datetime.min
611 |
612 | def populate_gameid_combo(self):
613 | game_ids_in_clips = {folder.split('_')[1] for folder in self.clip_folders}
614 | sorted_game_ids = sorted(game_ids_in_clips)
615 | self.gameid_combo.clear()
616 | self.gameid_combo.addItem("All Games")
617 | for game_id in sorted_game_ids:
618 | self.gameid_combo.addItem(self.get_game_name(game_id), game_id)
619 |
620 | def save_game_ids(self):
621 | with open(self.GAME_IDS_FILE, 'w') as f:
622 | json.dump(self.game_ids, f, indent=4)
623 |
624 | def filter_clips_by_gameid(self):
625 | selected_index = self.gameid_combo.currentIndex()
626 | if selected_index == 0:
627 | log_user_action("Selected All Games")
628 | self.clip_folders = [
629 | folder for folder in self.original_clip_folders
630 | if self.find_session_mpd(folder)
631 | ]
632 | else:
633 | selected_game_id = self.gameid_combo.itemData(selected_index)
634 | if not selected_game_id:
635 | return
636 | game_name = self.get_game_name(selected_game_id)
637 | log_user_action(f"Selected Game: {game_name} (ID: {selected_game_id})")
638 | self.clip_folders = [
639 | folder for folder in self.original_clip_folders
640 | if f'_{selected_game_id}_' in folder and self.find_session_mpd(folder)
641 | ]
642 | self.clip_index = 0
643 | self.display_clips()
644 |
645 | def display_clips(self):
646 | self.clear_clip_grid()
647 | valid_clip_folders = [
648 | folder for folder in self.clip_folders[self.clip_index:]
649 | if self.find_session_mpd(folder)
650 | ]
651 | clips_to_show = valid_clip_folders[:6]
652 | for index, folder in enumerate(clips_to_show):
653 | session_mpd_file = self.find_session_mpd(folder)
654 | thumbnail_path = os.path.join(folder, 'thumbnail.jpg')
655 | if session_mpd_file and not os.path.exists(thumbnail_path):
656 | self.extract_first_frame(session_mpd_file, thumbnail_path)
657 | if os.path.exists(thumbnail_path):
658 | self.add_thumbnail_to_grid(thumbnail_path, folder, index)
659 | placeholders_needed = 6 - len(clips_to_show)
660 | for i in range(placeholders_needed):
661 | placeholder = QFrame()
662 | placeholder.setFixedSize(300, 180)
663 | placeholder.setStyleSheet("border: none; background-color: transparent;")
664 | self.clip_grid.addWidget(placeholder, (len(clips_to_show) + i) // 3, (len(clips_to_show) + i) % 3)
665 | for i in range(self.clip_grid.count()):
666 | widget = self.clip_grid.itemAt(i).widget()
667 | if widget and hasattr(widget, 'folder') and widget.folder in self.selected_clips:
668 | widget.setStyleSheet("border: 3px solid lightblue;")
669 | self.update_navigation_buttons()
670 | self.export_all_button.setEnabled(bool(self.clip_folders))
671 |
672 | def extract_first_frame(self, session_mpd_path, output_thumbnail_path):
673 | ffmpeg_path = iio.get_ffmpeg_exe()
674 | data_dir = os.path.dirname(session_mpd_path)
675 | init_video = os.path.join(data_dir, 'init-stream0.m4s')
676 | chunk_video = glob.glob(os.path.join(data_dir, 'chunk-stream0-*.m4s'))
677 | if not os.path.exists(init_video) or not chunk_video:
678 | logging.error(f"Error extracting thumbnail: {e}")
679 | return
680 | try:
681 | with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp_video:
682 | with open(init_video, 'rb') as f:
683 | tmp_video.write(f.read())
684 | for chunk in sorted(chunk_video):
685 | with open(chunk, 'rb') as f:
686 | tmp_video.write(f.read())
687 | temp_video_path = tmp_video.name
688 | command = [
689 | ffmpeg_path,
690 | '-i', temp_video_path,
691 | '-ss', '00:00:00.000',
692 | '-vframes', '1',
693 | output_thumbnail_path
694 | ]
695 | subprocess.run(command, check=True)
696 | except subprocess.CalledProcessError as e:
697 | logging.error(f"Error extracting thumbnail: {e}")
698 | finally:
699 | if 'temp_video_path' in locals() and os.path.exists(temp_video_path):
700 | os.unlink(temp_video_path)
701 |
702 | def get_clip_duration(self, clip_folder):
703 | total_seconds = 0.0
704 | session_mpd_file = self.find_session_mpd(clip_folder)
705 | if not session_mpd_file:
706 | return "0:00"
707 | if os.path.exists(session_mpd_file):
708 | try:
709 | import xml.etree.ElementTree as ET
710 | tree = ET.parse(session_mpd_file)
711 | root = tree.getroot()
712 | ns = {'dash': 'urn:mpeg:dash:schema:mpd:2011'}
713 | mpd_element = root
714 | if 'mediaPresentationDuration' in mpd_element.attrib:
715 | duration_str = mpd_element.attrib['mediaPresentationDuration']
716 | duration_str = duration_str[2:]
717 | if 'H' in duration_str:
718 | hours, rest = duration_str.split('H')
719 | minutes, seconds = rest.split('M') if 'M' in rest else (rest[:-1], '0S')
720 | seconds = seconds.split('S')[0]
721 | total_seconds += int(hours) * 3600 + int(minutes) * 60 + float(seconds)
722 | elif 'M' in duration_str:
723 | minutes, seconds = duration_str.split('M')
724 | seconds = seconds.split('S')[0]
725 | total_seconds += int(minutes) * 60 + float(seconds)
726 | else:
727 | total_seconds += float(duration_str.split('S')[0])
728 | else:
729 | logging.warning(f"Attribute 'mediaPresentationDuration' not found in {session_mpd_file}")
730 | except Exception as e:
731 | logging.error(f"Error parsing {session_mpd_file}: {e}")
732 | else:
733 | logging.error(f"File {session_mpd_file} does not exist")
734 |
735 | minutes = int(total_seconds // 60)
736 | seconds = int(total_seconds % 60)
737 | return f"{minutes}:{seconds:02d}"
738 |
739 | def add_thumbnail_to_grid(self, thumbnail_path, folder, index):
740 | container = QFrame()
741 | container.setFixedSize(340, 200)
742 | container_layout = QVBoxLayout()
743 | container.setLayout(container_layout)
744 | pixmap = QPixmap(thumbnail_path).scaled(340, 200, Qt.KeepAspectRatio)
745 | thumbnail_label = QLabel()
746 | thumbnail_label.setPixmap(pixmap)
747 | thumbnail_label.setAlignment(Qt.AlignCenter)
748 | thumbnail_label.setStyleSheet("border: none;")
749 |
750 | def select_clip_event(event):
751 | self.select_clip(folder, container)
752 |
753 | thumbnail_label.mousePressEvent = select_clip_event
754 | container_layout.addWidget(thumbnail_label)
755 |
756 | duration = self.get_clip_duration(folder)
757 | duration_label = QLabel(f"{duration}", container)
758 | duration_label.setStyleSheet("font-size: 14px; color: white; background-color: rgba(0, 0, 0, 180); border-radius: 3px; border: none;")
759 | duration_label.setAlignment(Qt.AlignRight | Qt.AlignBottom)
760 | duration_label.setAttribute(Qt.WA_TransparentForMouseEvents, True)
761 | duration_label.adjustSize()
762 |
763 | duration_width = duration_label.width()
764 | duration_height = duration_label.height()
765 | x = 340 - duration_width - 20
766 | y = 200 - duration_height - 20
767 | duration_label.move(x, y)
768 |
769 | container.folder = folder
770 | self.clip_grid.addWidget(container, index // 3, index % 3)
771 |
772 | def select_clip(self, folder, container):
773 | if folder in self.selected_clips:
774 | log_user_action(f"Deselected clip: {folder}")
775 | self.selected_clips.remove(folder)
776 | container.setStyleSheet("border: none;")
777 | else:
778 | log_user_action(f"Selected clip: {folder}")
779 | self.selected_clips.add(folder)
780 | container.setStyleSheet("border: 3px solid lightblue;")
781 | self.convert_button.setEnabled(bool(self.selected_clips))
782 | self.clear_selection_button.setEnabled(len(self.selected_clips) >= 1)
783 |
784 | def update_navigation_buttons(self):
785 | self.prev_button.setEnabled(self.clip_index > 0)
786 | self.next_button.setEnabled(self.clip_index + 6 < len(self.clip_folders))
787 |
788 | def show_previous_clips(self):
789 | if self.clip_index - 6 >= 0:
790 | log_user_action("Navigated to previous clips")
791 | self.clip_index -= 6
792 | self.display_clips()
793 |
794 | def show_next_clips(self):
795 | if self.clip_index + 6 < len(self.clip_folders):
796 | log_user_action("Navigated to next clips")
797 | self.clip_index += 6
798 | self.display_clips()
799 |
800 | def update_progress(self, current_clip, total_clips, step, total_steps):
801 | clip_segment = 100 / total_clips
802 | step_progress = (step / total_steps) * clip_segment
803 | total_progress = (current_clip * clip_segment) + step_progress
804 | self.progress_bar.setValue(int(total_progress))
805 | QApplication.processEvents()
806 |
807 | def process_clips(self, selected_clips=None, export_all=False):
808 | if self.export_dir is None or not os.path.isdir(self.export_dir):
809 | logging.warning(f"Export directory '{self.export_dir}' not found.")
810 | reply = QMessageBox.critical(
811 | self,
812 | "!WARNING!",
813 | f"Directory '{self.export_dir}' not found.\n"
814 | "Use Desktop as export directory?",
815 | QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
816 | QMessageBox.StandardButton.Yes
817 | )
818 | if reply == QMessageBox.StandardButton.Yes:
819 | self.export_dir = os.path.expanduser("~/Desktop")
820 | self.save_config(self.default_dir, self.export_dir)
821 | QMessageBox.information(self, "Info", f"Export path set to: {self.export_dir}")
822 | else:
823 | QMessageBox.warning(self, "Operation Cancelled", "Export operation has been cancelled.")
824 | return
825 | QApplication.processEvents()
826 |
827 | if export_all:
828 | selected_game_index = self.gameid_combo.currentIndex()
829 | selected_media_type = self.media_type_combo.currentText()
830 | filtered_clips = self.original_clip_folders.copy()
831 | if selected_media_type == "Manual Clips":
832 | filtered_clips = [c for c in filtered_clips if "clips" in c]
833 | elif selected_media_type == "Background Recordings":
834 | filtered_clips = [c for c in filtered_clips if "video" in c]
835 | if selected_game_index > 0:
836 | game_id = self.gameid_combo.itemData(selected_game_index)
837 | filtered_clips = [c for c in filtered_clips if f"_{game_id}_" in c]
838 | clip_list = filtered_clips
839 | else:
840 | clip_list = list(selected_clips) if selected_clips else []
841 |
842 | if not clip_list:
843 | self.show_error("No clips to process")
844 | return
845 |
846 | output_dir = self.export_dir or os.path.expanduser("~/Desktop")
847 | ffmpeg_path = iio.get_ffmpeg_exe()
848 | errors = False
849 |
850 | total_clips = len(clip_list)
851 | self.progress_bar.setVisible(True)
852 | self.progress_bar.setRange(0, 100)
853 |
854 | try:
855 | for clip_idx, clip_folder in enumerate(clip_list):
856 | try:
857 | session_mpd_files = []
858 | for root, _, files in os.walk(clip_folder):
859 | if 'session.mpd' in files:
860 | session_mpd_files.append(os.path.join(root, 'session.mpd'))
861 |
862 | if not session_mpd_files:
863 | raise FileNotFoundError("No session.mpd files found")
864 | temp_video_paths = []
865 | temp_audio_paths = []
866 |
867 | for session_mpd in session_mpd_files:
868 | data_dir = os.path.dirname(session_mpd)
869 | init_video = os.path.join(data_dir, 'init-stream0.m4s')
870 | init_audio = os.path.join(data_dir, 'init-stream1.m4s')
871 |
872 | if not (os.path.exists(init_video) and os.path.exists(init_audio)):
873 | raise FileNotFoundError("Initialization files missing")
874 |
875 | with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp_video:
876 | with open(init_video, 'rb') as f:
877 | tmp_video.write(f.read())
878 | for chunk in sorted(glob.glob(os.path.join(data_dir, 'chunk-stream0-*.m4s'))):
879 | with open(chunk, 'rb') as f:
880 | tmp_video.write(f.read())
881 | temp_video_paths.append(tmp_video.name)
882 |
883 | with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp_audio:
884 | with open(init_audio, 'rb') as f:
885 | tmp_audio.write(f.read())
886 | for chunk in sorted(glob.glob(os.path.join(data_dir, 'chunk-stream1-*.m4s'))):
887 | with open(chunk, 'rb') as f:
888 | tmp_audio.write(f.read())
889 | temp_audio_paths.append(tmp_audio.name)
890 |
891 | concatenated_video = os.path.join(tempfile.gettempdir(), f"concat_video_{hash(clip_folder)}.mp4")
892 | concatenated_audio = os.path.join(tempfile.gettempdir(), f"concat_audio_{hash(clip_folder)}.mp4")
893 | video_list_file = os.path.join(tempfile.gettempdir(), f"video_list_{hash(clip_folder)}.txt")
894 | audio_list_file = os.path.join(tempfile.gettempdir(), f"audio_list_{hash(clip_folder)}.txt")
895 |
896 | with open(video_list_file, 'w') as f:
897 | for temp_video in temp_video_paths:
898 | f.write(f"file '{temp_video}'\n")
899 |
900 | with open(audio_list_file, 'w') as f:
901 | for temp_audio in temp_audio_paths:
902 | f.write(f"file '{temp_audio}'\n")
903 |
904 | subprocess.run([
905 | ffmpeg_path,
906 | '-f', 'concat',
907 | '-safe', '0',
908 | '-i', video_list_file,
909 | '-c', 'copy',
910 | concatenated_video
911 | ], check=True, creationflags=subprocess.CREATE_NO_WINDOW)
912 | self.update_progress(clip_idx, total_clips, 1, 3)
913 |
914 | subprocess.run([
915 | ffmpeg_path,
916 | '-f', 'concat',
917 | '-safe', '0',
918 | '-i', audio_list_file,
919 | '-c', 'copy',
920 | concatenated_audio
921 | ], check=True, creationflags=subprocess.CREATE_NO_WINDOW)
922 | self.update_progress(clip_idx, total_clips, 2, 3)
923 |
924 | game_id = os.path.basename(clip_folder).split('_')[1]
925 | game_name = self.get_game_name(game_id) or "Clip"
926 | output_file = self.get_unique_filename(output_dir, f"{game_name}.mp4")
927 |
928 | subprocess.run([
929 | ffmpeg_path,
930 | '-i', concatenated_video,
931 | '-i', concatenated_audio,
932 | '-c', 'copy',
933 | output_file
934 | ], check=True, creationflags=subprocess.CREATE_NO_WINDOW)
935 | self.update_progress(clip_idx, total_clips, 3, 3)
936 |
937 | except Exception as e:
938 | errors = True
939 | logging.error(f"Error processing {clip_folder}: {str(e)}")
940 | finally:
941 | for temp_video in temp_video_paths + temp_audio_paths:
942 | try:
943 | if os.path.exists(temp_video):
944 | os.unlink(temp_video)
945 | except Exception as e:
946 | logging.warning(f"Error cleaning up temp files: {str(e)}")
947 | try:
948 | if 'concatenated_video' in locals() and os.path.exists(concatenated_video):
949 | os.unlink(concatenated_video)
950 | if 'concatenated_audio' in locals() and os.path.exists(concatenated_audio):
951 | os.unlink(concatenated_audio)
952 | if 'video_list_file' in locals() and os.path.exists(video_list_file):
953 | os.unlink(video_list_file)
954 | if 'audio_list_file' in locals() and os.path.exists(audio_list_file):
955 | os.unlink(audio_list_file)
956 | except Exception as e:
957 | logging.warning(f"Error cleaning up concatenated files: {str(e)}")
958 |
959 | if export_all:
960 | msg = "All clips converted successfully" if not errors else "Some clips failed"
961 | self.show_info(msg)
962 | else:
963 | self.selected_clips.clear()
964 | self.display_clips()
965 | self.show_info("Selected clips converted successfully")
966 | return not errors
967 | finally:
968 | self.progress_bar.setVisible(False)
969 |
970 | # Thanks to User /u/vanokhin for the suggestions!
971 |
972 | def convert_clip(self):
973 | QApplication.processEvents()
974 | self.process_clips(selected_clips=self.selected_clips)
975 |
976 | def export_all(self):
977 | QApplication.processEvents()
978 | self.process_clips(export_all=True)
979 |
980 | def find_session_mpd(self, clip_folder):
981 | for root, _, files in os.walk(clip_folder):
982 | if 'session.mpd' in files:
983 | return os.path.join(root, 'session.mpd')
984 | return None
985 |
986 | def get_unique_filename(self, directory, filename):
987 | base_name, ext = os.path.splitext(filename)
988 | counter = 1
989 | unique_filename = os.path.join(directory, filename)
990 | while os.path.exists(unique_filename):
991 | unique_filename = os.path.join(directory, f"{base_name}_{counter}{ext}")
992 | counter += 1
993 | return unique_filename
994 |
995 | def show_error(self, message):
996 | QMessageBox.critical(self, "Error", message)
997 |
998 | def show_info(self, message):
999 | QMessageBox.information(self, "Info", message)
1000 |
1001 | def open_settings(self):
1002 | self.settings_window = SettingsWindow(self)
1003 | self.settings_window.exec_()
1004 |
1005 | def debug_crash(self):
1006 | log_user_action("Debug button pressed - Simulating crash")
1007 | raise Exception("Test crash")
1008 |
1009 |
1010 | class SteamVersionSelectionDialog(QDialog):
1011 | def __init__(self, parent):
1012 | super().__init__(parent)
1013 | self.setWindowTitle("Select Steam Version")
1014 | self.setFixedSize(350, 150)
1015 | layout = QVBoxLayout()
1016 | self.standard_button = QPushButton("Standard")
1017 | #self.flatpak_button = QPushButton("Flatpak")
1018 | self.manual_button = QPushButton("Select the userdata folder manually")
1019 | self.standard_button.clicked.connect(lambda: self.accept_and_set("Standard"))
1020 | #self.flatpak_button.clicked.connect(lambda: self.accept_and_set("Flatpak"))
1021 | self.manual_button.clicked.connect(self.select_userdata_folder)
1022 | layout.addWidget(QLabel("What version of Steam are you using?"))
1023 | layout.addWidget(self.standard_button)
1024 | #layout.addWidget(self.flatpak_button)
1025 | layout.addWidget(self.manual_button)
1026 | self.setLayout(layout)
1027 | self.selected_version = None
1028 |
1029 | def accept_and_set(self, version):
1030 | self.selected_version = version
1031 | self.accept()
1032 |
1033 | def select_userdata_folder(self):
1034 | userdata_path = QFileDialog.getExistingDirectory(self, "Select userdata folder")
1035 | if userdata_path:
1036 | if self.is_valid_userdata_folder(userdata_path):
1037 | self.selected_version = userdata_path
1038 | self.accept()
1039 | else:
1040 | QMessageBox.warning(self, "Invalid Directory", "The selected directory is not a valid userdata folder.")
1041 |
1042 | def is_valid_userdata_folder(self, folder):
1043 | if not os.path.basename(folder) == "userdata":
1044 | return False
1045 | steam_id_dirs = [d for d in os.listdir(folder) if os.path.isdir(os.path.join(folder, d)) and d.isdigit()]
1046 | if not steam_id_dirs:
1047 | return False
1048 | for steam_id in steam_id_dirs:
1049 | clips_path = os.path.join(folder, steam_id, 'gamerecordings')
1050 | if os.path.isdir(clips_path):
1051 | return True
1052 | return False
1053 |
1054 | def get_selected_option(self):
1055 | return self.selected_version
1056 |
1057 |
1058 | class SettingsWindow(QDialog):
1059 | def __init__(self, parent):
1060 | super().__init__(parent)
1061 | self.setWindowTitle("Settings")
1062 | self.setFixedSize(220, 400)
1063 | layout = QVBoxLayout()
1064 | self.open_config_button = self.create_button("Open Config Folder", self.open_config_folder, "folder-open")
1065 | self.edit_game_ids_button = self.create_button("Edit Game Name", self.open_edit_game_ids, "edit-rename")
1066 | self.update_game_ids_button = self.create_button("Update GameIDs", self.update_game_ids, "view-refresh")
1067 | self.check_for_updates_button = self.create_button("Check for Updates", self.check_for_updates_in_settings, "view-refresh")
1068 | self.close_settings_button = self.create_button("Close Settings", self.close, "window-close")
1069 | self.select_export_button = self.create_button("Set Export Path", self.select_export_path, "folder-open")
1070 | self.delete_config_button = self.create_button("Delete Config Folder", self.delete_config_folder, "edit-delete")
1071 | self.version_label = QLabel(f"Version: {parent.CURRENT_VERSION}")
1072 | self.version_label.setAlignment(Qt.AlignLeft)
1073 | self.setLayout(layout)
1074 | layout.addWidget(self.open_config_button)
1075 | layout.addWidget(self.select_export_button)
1076 | layout.addWidget(self.edit_game_ids_button)
1077 | layout.addWidget(self.update_game_ids_button)
1078 | layout.addWidget(self.check_for_updates_button)
1079 | layout.addWidget(self.delete_config_button)
1080 | layout.addWidget(self.close_settings_button)
1081 | layout.addWidget(self.version_label)
1082 |
1083 | def select_export_path(self):
1084 | export_path = QFileDialog.getExistingDirectory(self, "Set Export Folder")
1085 | if export_path and os.path.isdir(export_path):
1086 | try:
1087 | test_file = os.path.join(export_path, ".test_write_permission")
1088 | with open(test_file, 'w') as f:
1089 | f.write("test")
1090 | os.remove(test_file)
1091 | self.parent().export_dir = export_path
1092 | self.parent().save_config(self.parent().default_dir, self.parent().export_dir)
1093 | QMessageBox.information(self, "Info", f"Export path set to: {export_path}")
1094 | return
1095 | except Exception as e:
1096 | QMessageBox.warning(self, "Invalid Directory",
1097 | f"The selected directory is not writable: {str(e)}")
1098 | default_export_path = os.path.expanduser("~/Desktop")
1099 | self.parent().export_dir = default_export_path
1100 | self.parent().save_config(self.parent().default_dir, default_export_path)
1101 | QMessageBox.warning(self, "Invalid Directory",
1102 | f"Selected export directory is invalid. Using default: {default_export_path}")
1103 |
1104 | def create_button(self, text, slot, icon=None, size=(200, 45)):
1105 | button = QPushButton(text)
1106 | button.clicked.connect(slot)
1107 | if icon:
1108 | button.setIcon(QIcon.fromTheme(icon))
1109 | if size:
1110 | button.setFixedSize(*size)
1111 | return button
1112 |
1113 | def check_for_updates_in_settings(self):
1114 | release_info = self.parent().perform_update_check(show_message=False)
1115 | if release_info is None:
1116 | QMessageBox.critical(self, "Error", "Failed to fetch the latest release information.")
1117 | return
1118 | if release_info['version'] == self.parent().CURRENT_VERSION:
1119 | QMessageBox.information(self, "No Updates Available", "You are already using the latest version of SteamClip.")
1120 | else:
1121 | self.parent().prompt_update(release_info['version'], release_info['changelog'])
1122 |
1123 | def open_edit_game_ids(self):
1124 | edit_window = EditGameIDWindow(self.parent())
1125 | edit_window.exec_()
1126 |
1127 | def open_config_folder(self):
1128 | config_folder = SteamClipApp.CONFIG_DIR
1129 | os.makedirs(config_folder, exist_ok=True)
1130 | if sys.platform.startswith('linux'):
1131 | subprocess.run(['xdg-open', config_folder])
1132 | elif sys.platform == 'darwin':
1133 | subprocess.run(['open', config_folder])
1134 | elif sys.platform == 'win32':
1135 | subprocess.run(['explorer', os.path.normpath(config_folder)])
1136 |
1137 | def update_game_ids(self):
1138 | try:
1139 | if not self.parent().is_connected():
1140 | return QMessageBox.warning(self, "Warning", "No internet connection")
1141 |
1142 | game_ids = {folder.split('_')[1] for folder in self.parent().original_clip_folders}
1143 | updated = False
1144 |
1145 | for game_id in game_ids:
1146 | if game_id not in self.parent().game_ids:
1147 | try:
1148 | name = self.parent().fetch_game_name_from_steam(game_id)
1149 | if name:
1150 | self.parent().game_ids[game_id] = name
1151 | updated = True
1152 | except Exception as e:
1153 | logging.error(f"Failed to fetch name for {game_id}: {e}")
1154 |
1155 | if updated:
1156 | self.parent().save_game_ids()
1157 | self.parent().populate_gameid_combo()
1158 | QMessageBox.information(self, "Success", "Game ID database updated")
1159 | else:
1160 | QMessageBox.information(self, "Info", "No updates needed")
1161 |
1162 | except Exception as e:
1163 | QMessageBox.critical(self, "Error", f"Update failed: {str(e)}")
1164 |
1165 | def delete_config_folder(self):
1166 | reply = QMessageBox.question(
1167 | self,
1168 | "Confirm Deletion",
1169 | f"Are you sure you want to delete the entire configuration folder?\n\n{SteamClipApp.CONFIG_DIR}\n\nThis action cannot be undone.",
1170 | QMessageBox.Yes | QMessageBox.No,
1171 | QMessageBox.No
1172 | )
1173 | if reply == QMessageBox.Yes:
1174 | try:
1175 | shutil.rmtree(SteamClipApp.CONFIG_DIR)
1176 | QMessageBox.information(self, "Deletion Complete", "Configuration folder has been deleted.\nThe application will now close.")
1177 | QApplication.quit()
1178 | except Exception as e:
1179 | QMessageBox.critical(self, "Error", f"Failed to delete configuration folder:\n{str(e)}")
1180 |
1181 | class EditGameIDWindow(QDialog):
1182 | def __init__(self, parent):
1183 | super().__init__(parent)
1184 | self.setWindowTitle("Edit Game Names")
1185 | self.setFixedSize(400, 300)
1186 | self.layout = QVBoxLayout()
1187 | self.table_widget = QTableWidget()
1188 | self.populate_table()
1189 | self.layout.addWidget(self.table_widget)
1190 | self.layout.addLayout(self.create_button_layout())
1191 | self.setLayout(self.layout)
1192 |
1193 | def populate_table(self):
1194 | self.game_names = {
1195 | self.parent().gameid_combo.itemData(i): self.parent().gameid_combo.itemText(i)
1196 | for i in range(1, self.parent().gameid_combo.count())
1197 | }
1198 | self.table_widget.setRowCount(len(self.game_names))
1199 | self.table_widget.setColumnCount(1)
1200 | self.table_widget.setHorizontalHeaderLabels(["Game Name"])
1201 | for row, (game_id, game_name) in enumerate(self.game_names.items()):
1202 | name_item = QTableWidgetItem(game_name)
1203 | name_item.setData(Qt.UserRole, game_id)
1204 | self.table_widget.setItem(row, 0, name_item)
1205 | self.table_widget.horizontalHeader().setStretchLastSection(True)
1206 |
1207 | def create_button_layout(self):
1208 | button_layout = QHBoxLayout()
1209 | button_layout.addWidget(self.create_button("Cancel", self.reject))
1210 | button_layout.addWidget(self.create_button("Apply Changes", self.save_changes))
1211 | return button_layout
1212 |
1213 | def create_button(self, text, slot):
1214 | button = QPushButton(text)
1215 | button.clicked.connect(slot)
1216 | return button
1217 |
1218 | def save_changes(self):
1219 | updated_game_names = {}
1220 | for row in range(self.table_widget.rowCount()):
1221 | item = self.table_widget.item(row, 0)
1222 | if item:
1223 | game_id = item.data(Qt.UserRole)
1224 | new_name = item.text()
1225 | updated_game_names[game_id] = new_name
1226 | game_ids_file = os.path.join(SteamClipApp.CONFIG_DIR, 'GameIDs.json')
1227 | with open(game_ids_file, 'w') as f:
1228 | json.dump(updated_game_names, f, indent=4)
1229 | QMessageBox.information(self, "Info", "Game names saved successfully.")
1230 | self.parent().load_game_ids()
1231 | self.parent().populate_gameid_combo()
1232 |
1233 |
1234 | if __name__ == "__main__":
1235 | sys.excepthook = handle_exception
1236 | app = QApplication(sys.argv)
1237 | app.setStyleSheet("""
1238 | QWidget {
1239 | font-size: 16px;
1240 | }
1241 | QLabel {
1242 | font-size: 18px;
1243 | }
1244 | QPushButton {
1245 | font-size: 16px;
1246 | }
1247 | QComboBox {
1248 | font-size: 16px;
1249 | combobox-popup: 0;
1250 | }
1251 | QTableWidget {
1252 | font-size: 16px;
1253 | }
1254 | """)
1255 | try:
1256 | window = SteamClipApp()
1257 | window.show()
1258 | sys.exit(app.exec_())
1259 | except Exception as e:
1260 | handle_exception(type(e), e, e.__traceback__)
1261 |
--------------------------------------------------------------------------------