├── .gitignore ├── .mypy.ini ├── .prospector.yaml ├── COPYING ├── README.md ├── SBtools ├── __init__.py ├── asgi.py ├── settings │ ├── common.py │ ├── development.py │ └── production.py ├── urls.py └── wsgi.py ├── browser ├── __init__.py ├── apps.py ├── columns.py ├── filters.py ├── migrations │ └── __init__.py ├── models.py ├── static │ └── browser │ │ ├── logo.png │ │ ├── sbtools.js │ │ └── style.css ├── tables.py ├── templates │ ├── browser │ │ ├── 404.html │ │ ├── base.html │ │ ├── index.html │ │ ├── snippets │ │ │ ├── userid_details.html │ │ │ └── videodetails.html │ │ ├── table.html │ │ ├── userid.html │ │ ├── username.html │ │ ├── uuid.html │ │ └── video.html │ └── django_filters │ │ └── widgets │ │ └── multiwidget.html ├── templatetags │ ├── __init__.py │ └── browser_extras.py ├── tests.py ├── urls.py └── views.py ├── docs ├── sbtools.nginx.conf └── sbtools.service ├── manage.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | /venv/ 2 | /.idea/ 3 | __pycache__/ -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = 3 | mypy_django_plugin.main 4 | 5 | [mypy.plugins.django-stubs] 6 | django_settings_module = "SBtools.settings.development" 7 | 8 | [mypy-*.migrations.*] 9 | ignore_errors = True 10 | -------------------------------------------------------------------------------- /.prospector.yaml: -------------------------------------------------------------------------------- 1 | output-format: pylint 2 | strictness: veryhigh 3 | 4 | uses: 5 | - django 6 | 7 | ignore-paths: 8 | - .mypy_cache 9 | - migrations 10 | - node_modules 11 | - venv 12 | - __pycache__ 13 | - manage.py 14 | 15 | mypy: 16 | run: false 17 | 18 | bandit: 19 | run: true 20 | 21 | dodgy: 22 | run: false 23 | 24 | pylint: 25 | enable: 26 | - useless-suppression 27 | options: 28 | max-line-length: 120 29 | max-parents: 12 30 | django-settings-module: SBtools.settings.development 31 | 32 | pycodestyle: 33 | options: 34 | max-line-length: 120 -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 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 Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SBTools 2 | SBTools is a Django web service for browsing the SponsorBlock database (https://sponsor.ajay.app/). 3 | The project is tested to work on Python 3.8. 4 | 5 | There is a publicly hosted instance of this service at https://sb.ltn.fi/ 6 | 7 | ## Installation & Usage 8 | 9 | To install the requirements on Linux, `psycopg2` needs to be compiled. pip does this automatically if build requirements are available. https://www.psycopg.org/docs/install.html#build-prerequisites 10 | 11 | An alternative to building is to use `psycopg2-binary`. You can edit requirements.txt or install it manually if you can't or don't want to build. 12 | The binary version is not recommended for production by psycopg2 developers and could in certain situations lead to problems, which is why we default to building. https://www.psycopg.org/docs/install.html#psycopg-vs-psycopg-binary 13 | 14 | ### Development 15 | For development you can clone the repo, install requirements.txt (in a venv preferably) and run the Django development server with `(venv) python manage.py runserver`. 16 | 17 | Database should still be migrated to PostgreSQL (e.g. `pgloader database.db postgresql://sponsorblock@localhost/sponsorblock`) and modified with `INSERT INTO config VALUES ('updated', now());` or similar. 18 | Database connection details are configured in SBtools/settings/development.py 19 | The SECRET_KEY doesn't need changing necessarily. 20 | 21 | ### Production 22 | There are helpful files under docs/ for deploying the service. 23 | 24 | All examples assume a Linux user `sponsorblock` with home folder at `/srv/sponsorblock/` 25 | and proxying the site through Nginx with webroot at `/srv/http/sbtools/` 26 | 27 | You should also have a PostgreSQL user `sponsorblock` with a database called `sponsorblock`. 28 | Based on brief testing, using the SQLite database has abysmal performance. 29 | 30 | Installing before running could look like the following 31 | 32 | ```bash 33 | [sponsorblock]$ git clone https://github.com/Lartza/SBbrowser.git 34 | [sponsorblock]$ python -m venv /srv/sponsorblock/SBbrowser/venv 35 | [sponsorblock]$ /srv/sponsorblock/SBbrowser/venv/bin/pip install -r /srv/sponsorblock/SBbrowser/requirements.txt 36 | [sponsorblock]$ /srv/sponsorblock/SBbrowser/venv/bin/pip install gunicorn 37 | [sponsorblock]$ DB_PASSWORD='changeme' SECRET_KEY='changeme' STATIC_ROOT='/srv/http/sbtools/static/' DJANGO_SETTINGS_MODULE='SBtools.settings.production' /srv/sponsorblock/SBbrowser/venv/bin/python /srv/sponsorblock/SBbrowser/manage.py collectstatic --noinput 38 | ``` 39 | 40 | The DB_PASSWORD, SECRET_KEY and STATIC_ROOT variables are also present in files under docs/ and should be modified as needed. 41 | SECRET_KEY should be a large random value and kept secret. You could generate one on https://djecrety.ir/ 42 | 43 | ## Contributing 44 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 45 | 46 | ## License 47 | [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html) -------------------------------------------------------------------------------- /SBtools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lartza/SBbrowser/4a067115dc7def4f811d9a625a22a89b20077549/SBtools/__init__.py -------------------------------------------------------------------------------- /SBtools/asgi.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | """ 3 | ASGI config for SBtools project. 4 | 5 | It exposes the ASGI callable as a module-level variable named ``application``. 6 | 7 | For more information on this file, see 8 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 9 | """ 10 | 11 | import os 12 | 13 | from django.core.asgi import get_asgi_application 14 | 15 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'SBtools.settings.development') 16 | 17 | application = get_asgi_application() 18 | -------------------------------------------------------------------------------- /SBtools/settings/common.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | """ 3 | Django settings for SBtools project. 4 | 5 | Generated by 'django-admin startproject' using Django 3.0.7. 6 | 7 | For more information on this file, see 8 | https://docs.djangoproject.com/en/3.0/topics/settings/ 9 | 10 | For the full list of settings and their values, see 11 | https://docs.djangoproject.com/en/3.0/ref/settings/ 12 | """ 13 | 14 | import os 15 | 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | ALLOWED_HOSTS = ['*'] 19 | 20 | INSTALLED_APPS = [ 21 | 'browser.apps.BrowserConfig', 22 | 'django_tables2', 23 | 'django_filters', 24 | 'django_bootstrap5', 25 | 'django.contrib.contenttypes', 26 | 'django.contrib.staticfiles', 27 | ] 28 | 29 | MIDDLEWARE = [ 30 | 'django.middleware.cache.UpdateCacheMiddleware', 31 | 'django.middleware.common.CommonMiddleware', 32 | 'django.middleware.csrf.CsrfViewMiddleware', 33 | 'django.middleware.cache.FetchFromCacheMiddleware', 34 | ] 35 | 36 | ROOT_URLCONF = 'SBtools.urls' 37 | 38 | TEMPLATES = [ 39 | { 40 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 41 | 'DIRS': [os.path.join(BASE_DIR, 'browser/../../browser/templates')], 42 | 'APP_DIRS': True, 43 | 'OPTIONS': { 44 | 'context_processors': [ 45 | 'django.template.context_processors.debug', 46 | 'django.template.context_processors.request', 47 | ], 48 | }, 49 | }, 50 | ] 51 | 52 | WSGI_APPLICATION = 'SBtools.wsgi.application' 53 | 54 | AUTH_PASSWORD_VALIDATORS = [ 55 | { 56 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 57 | }, 58 | { 59 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 60 | }, 61 | { 62 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 63 | }, 64 | { 65 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 66 | }, 67 | ] 68 | 69 | LANGUAGE_CODE = 'en-us' 70 | 71 | TIME_ZONE = 'UTC' 72 | 73 | USE_I18N = True 74 | 75 | USE_L10N = True 76 | 77 | USE_TZ = True 78 | 79 | STATIC_URL = '/static/' 80 | 81 | LOGGING = { 82 | 'version': 1, 83 | 'disable_existing_loggers': False, 84 | 'handlers': { 85 | 'console': { 86 | 'class': 'logging.StreamHandler', 87 | }, 88 | }, 89 | 'root': { 90 | 'handlers': ['console'], 91 | 'level': 'WARNING', 92 | }, 93 | } 94 | 95 | CACHE_MIDDLEWARE_SECONDS = 30 96 | -------------------------------------------------------------------------------- /SBtools/settings/development.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | from SBtools.settings.common import * # noqa 3 | 4 | SECRET_KEY = '70wv4w$tv1suzuf1-2_h9-p%#!qtsz7%(90x%4a=@3yw6rz%0#' # noqa 5 | 6 | DEBUG = True 7 | DEBUG_PROPAGATE_EXCEPTIONS = True 8 | 9 | DATABASES = { 10 | 'default': { 11 | 'ENGINE': 'django.db.backends.postgresql', 12 | 'NAME': 'sponsorblock', 13 | 'USER': 'sponsorblock', 14 | 'PASSWORD': '', 15 | 'HOST': '127.0.0.1', 16 | } 17 | } 18 | 19 | CACHES = { 20 | 'default': { 21 | 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 22 | 'LOCATION': 'redis://127.0.0.1:6379', 23 | } 24 | } 25 | 26 | INTERNAL_IPS = [ 27 | "127.0.0.1", 28 | ] 29 | -------------------------------------------------------------------------------- /SBtools/settings/production.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | from os import environ 3 | from SBtools.settings.common import * # noqa 4 | 5 | SECRET_KEY = environ['SECRET_KEY'] 6 | 7 | DEBUG = False 8 | 9 | DATABASES = { 10 | 'default': { 11 | 'ENGINE': 'django.db.backends.postgresql', 12 | 'NAME': 'sponsorblock', 13 | 'USER': 'sponsorblock', 14 | 'PASSWORD': environ['DB_PASSWORD'], 15 | 'HOST': '', 16 | } 17 | } 18 | 19 | SESSION_COOKIE_SECURE = True 20 | CSRF_COOKIE_SECURE = True 21 | SECURE_SSL_REDIRECT = True 22 | 23 | STATIC_ROOT = environ['STATIC_ROOT'] 24 | 25 | CACHES = { 26 | 'default': { 27 | 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 28 | 'LOCATION': 'unix:///var/run/redis/redis-server.sock', 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /SBtools/urls.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | """SBtools URL Configuration 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | # from django.contrib import admin 18 | from django.urls import path, include 19 | 20 | handler404 = 'browser.views.view_404' 21 | 22 | urlpatterns = [ 23 | # path('admin/', admin.site.urls), 24 | path('', include('browser.urls')), 25 | ] 26 | -------------------------------------------------------------------------------- /SBtools/wsgi.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | """ 3 | WSGI config for SBtools project. 4 | 5 | It exposes the WSGI callable as a module-level variable named ``application``. 6 | 7 | For more information on this file, see 8 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 9 | """ 10 | 11 | import os 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | 15 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'SBtools.settings.development') 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /browser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lartza/SBbrowser/4a067115dc7def4f811d9a625a22a89b20077549/browser/__init__.py -------------------------------------------------------------------------------- /browser/apps.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | from django.apps import AppConfig 3 | 4 | 5 | class BrowserConfig(AppConfig): 6 | name = 'browser' 7 | -------------------------------------------------------------------------------- /browser/columns.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | import datetime 3 | import django_tables2 as tables 4 | 5 | from django.db.models import F, QuerySet 6 | from django.utils.html import format_html 7 | 8 | 9 | class LengthColumn(tables.Column): 10 | def render(self, value: float) -> str: 11 | time = str(datetime.timedelta(seconds=value)) 12 | try: 13 | time, decimal = time.split('.') 14 | decimal = decimal.rstrip('0') 15 | if len(decimal) > 3: 16 | return format_html('{}.{}', time, decimal) 17 | return f'{time}.{decimal}' 18 | except ValueError: 19 | return time 20 | 21 | def order(self, queryset: QuerySet, is_descending: bool) -> (QuerySet, bool): 22 | queryset = queryset.annotate( 23 | length=F("endtime") - F("starttime") 24 | ).order_by(("-" if is_descending else "") + "length") 25 | return queryset, True 26 | -------------------------------------------------------------------------------- /browser/filters.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | from django_filters import FilterSet, CharFilter, ChoiceFilter, MultipleChoiceFilter, RangeFilter 3 | from django_filters.widgets import RangeWidget 4 | 5 | from .models import Sponsortime 6 | 7 | FIELDS = ['videoid', 'votes', 'views', 'category', 'shadowhidden', 'uuid', 'username', 'user'] 8 | 9 | 10 | class CustomRangeWidget(RangeWidget): 11 | def __init__(self, attrs=None, from_attrs=None, to_attrs=None): 12 | super().__init__(attrs) 13 | 14 | if from_attrs: 15 | self.widgets[0].attrs.update(from_attrs) 16 | if to_attrs: 17 | self.widgets[1].attrs.update(to_attrs) 18 | 19 | 20 | class UserIDFilter(FilterSet): 21 | votes = RangeFilter(widget=CustomRangeWidget(attrs={'type': 'number', 'step': 1}, 22 | from_attrs={'placeholder': 'Votes from'}, 23 | to_attrs={'placeholder': 'Votes to'})) 24 | views = RangeFilter(widget=CustomRangeWidget(attrs={'type': 'number', 'step': 1}, 25 | from_attrs={'placeholder': 'Views from'}, 26 | to_attrs={'placeholder': 'Views to'})) 27 | category = MultipleChoiceFilter(choices=(('chapter', 'Chapter'), ('exclusive_access', 'Exclusive Access'), 28 | ('filler', 'Filler'), ('poi_highlight', 'Highlight'), 29 | ('interaction', 'Interaction'), ('intro', 'Intro'), 30 | ('music_offtopic', 'Non-Music'), ('outro', 'Outro'), 31 | ('preview', 'Preview'), ('selfpromo', 'Selfpromo'), 32 | ('sponsor', 'Sponsor'),), distinct=False) 33 | category.always_filter = False 34 | shadowhidden = ChoiceFilter(choices=((0, 'No'), (1, 'Yes')), empty_label='Shadowhidden', 35 | method='shadowhidden_filter') 36 | actiontype = MultipleChoiceFilter(choices=(('chapter', 'Chapter'), ('full', 'Full Video Label'), 37 | ('poi', 'Highlight'), ('mute', 'Mute'), ('skip', 'Skip')), 38 | distinct=False) 39 | 40 | class Meta: 41 | model = Sponsortime 42 | fields = FIELDS 43 | exclude = ['username', 'user'] 44 | 45 | @staticmethod 46 | def shadowhidden_filter(queryset, _name, value): 47 | if value == 1: 48 | return queryset.filter(shadowhidden__gte=1) 49 | return queryset.filter(shadowhidden=0) 50 | 51 | 52 | class UsernameFilter(UserIDFilter): 53 | user = CharFilter() 54 | 55 | class Meta: 56 | model = Sponsortime 57 | fields = FIELDS 58 | exclude = 'username' 59 | 60 | 61 | class SponsortimeFilter(UsernameFilter): 62 | username = CharFilter(field_name='user__username', label='Username', lookup_expr='icontains') 63 | 64 | class Meta: 65 | model = Sponsortime 66 | fields = FIELDS 67 | 68 | 69 | class VideoFilter(SponsortimeFilter): 70 | class Meta: 71 | model = Sponsortime 72 | fields = FIELDS 73 | exclude = 'videoid' 74 | -------------------------------------------------------------------------------- /browser/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lartza/SBbrowser/4a067115dc7def4f811d9a625a22a89b20077549/browser/migrations/__init__.py -------------------------------------------------------------------------------- /browser/models.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | from django.db import models 3 | 4 | 5 | class Config(models.Model): 6 | key = models.TextField(primary_key=True) 7 | value = models.TextField() 8 | 9 | class Meta: 10 | managed = False 11 | db_table = 'config' 12 | 13 | 14 | class Username(models.Model): 15 | userid = models.TextField(primary_key=True, db_column='userID') 16 | username = models.TextField(verbose_name='Username', db_column='userName') 17 | locked = models.IntegerField(default=0) 18 | 19 | class Meta: 20 | managed = False 21 | db_table = 'userNames' 22 | 23 | def __str__(self) -> str: 24 | return self.userid 25 | 26 | 27 | class Vipuser(models.Model): 28 | userid = models.TextField(primary_key=True, db_column='userID') 29 | 30 | class Meta: 31 | managed = False 32 | db_table = 'vipUsers' 33 | 34 | 35 | class Lockcategory(models.Model): 36 | videoid = models.TextField(db_column='videoID') 37 | userid = models.TextField(db_column='userID') 38 | actiontype = models.TextField(default='skip', db_column='actionType') 39 | category = models.TextField() 40 | hashedvideoid = models.TextField(blank=True, default='', db_column='hashedVideoID') 41 | reason = models.TextField(blank=True, default='') 42 | service = models.TextField(default='YouTube') 43 | id = models.AutoField(primary_key=True) 44 | 45 | class Meta: 46 | managed = False 47 | db_table = 'lockCategories' 48 | 49 | 50 | class Sponsortime(models.Model): 51 | videoid = models.TextField(verbose_name='Video ID', db_column='videoID') 52 | starttime = models.FloatField(verbose_name='Start', db_column='startTime') 53 | endtime = models.FloatField(verbose_name='End', db_column='endTime') 54 | votes = models.IntegerField() 55 | locked = models.IntegerField(default=0) 56 | incorrectvotes = models.IntegerField(default=1, db_column='incorrectVotes') 57 | uuid = models.TextField(primary_key=True, verbose_name='UUID', db_column='UUID') 58 | user = models.ForeignKey(Username, on_delete=models.PROTECT, db_constraint=False, verbose_name='UserID', 59 | db_column='userID') 60 | timesubmitted = models.BigIntegerField(verbose_name='Submitted', db_column='timeSubmitted') 61 | views = models.IntegerField() 62 | category = models.TextField(default='sponsor') 63 | actiontype = models.TextField(default='skip', db_column='actionType') 64 | service = models.TextField(default='YouTube') 65 | videoduration = models.FloatField(default=0, db_column='videoDuration') 66 | hidden = models.IntegerField(default=0) 67 | reputation = models.FloatField(default=0) 68 | shadowhidden = models.IntegerField(verbose_name='Shadowhidden', db_column='shadowHidden') 69 | hashedvideoid = models.TextField(blank=True, default='', db_column='hashedVideoID') 70 | useragent = models.TextField(blank=True, default='', db_column='userAgent') 71 | description = models.TextField(blank=True, default='') 72 | 73 | class Meta: 74 | managed = False 75 | db_table = 'sponsorTimes' 76 | 77 | def ignored(self) -> int: 78 | return self.votes <= -2 79 | 80 | def length(self) -> float: 81 | return self.endtime - self.starttime 82 | 83 | 84 | class Categoryvote(models.Model): 85 | uuid = models.TextField(db_column='UUID') 86 | category = models.TextField() 87 | votes = models.IntegerField(default=0) 88 | id = models.AutoField(primary_key=True) 89 | 90 | class Meta: 91 | managed = False 92 | db_table = 'categoryVotes' 93 | 94 | 95 | class Warnings(models.Model): 96 | userid = models.TextField(db_column='userID') 97 | issuetime = models.IntegerField(db_column='issueTime', primary_key=True) 98 | issueruserid = models.TextField(db_column='issuerUserID') 99 | enabled = models.IntegerField() 100 | reason = models.TextField(blank=True, default='') 101 | 102 | class Meta: 103 | managed = False 104 | db_table = 'warnings' 105 | -------------------------------------------------------------------------------- /browser/static/browser/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lartza/SBbrowser/4a067115dc7def4f811d9a625a22a89b20077549/browser/static/browser/logo.png -------------------------------------------------------------------------------- /browser/static/browser/sbtools.js: -------------------------------------------------------------------------------- 1 | $('form').submit(function() { 2 | $(':input', this).each(function() { 3 | this.disabled = !($(this).val()); 4 | }); 5 | }); 6 | 7 | $('.formreset').click(resetForm); 8 | function resetForm() { 9 | const $form = $('#filterForm') 10 | $form.find('input:text, input:password, input:file, select, textarea').val(''); 11 | $form.find(':input[type=number]').val(''); 12 | $form.find('input:radio, input:checkbox') 13 | .removeAttr('checked').removeAttr('selected'); 14 | } 15 | 16 | document.querySelector("#darkmode").onclick = function(){ 17 | darkmode.toggleDarkMode(); 18 | } 19 | 20 | const elements = document.querySelectorAll('.clip') 21 | for (let i = 0, element; element = elements[i]; i++) { 22 | element.addEventListener('click', function() { 23 | navigator.clipboard.writeText(element.dataset.value); 24 | }); 25 | } 26 | 27 | window.onpagehide = function(){}; 28 | -------------------------------------------------------------------------------- /browser/static/browser/style.css: -------------------------------------------------------------------------------- 1 | html.dark { 2 | color-scheme: dark; 3 | } 4 | 5 | a { 6 | text-decoration: none; 7 | } 8 | 9 | html.dark a { 10 | color: #6E8CAA; 11 | } 12 | 13 | html.dark .page-link { 14 | color: #91A8C0; 15 | } 16 | 17 | html.dark .form-control { 18 | border-color: #8A8A8A; 19 | } 20 | 21 | html.dark .bg-light { 22 | background-color: #737373 !important; 23 | } 24 | 25 | nav img, .navbar-brand { 26 | max-height: 40px; 27 | } 28 | 29 | textarea { 30 | resize: horizontal; 31 | } 32 | 33 | table button { 34 | border: none; 35 | background: none; 36 | } 37 | 38 | .page-link { 39 | white-space: nowrap; 40 | } 41 | 42 | .uuid { 43 | max-width: 150px; 44 | } 45 | 46 | .userid { 47 | max-width: 200px; 48 | } 49 | 50 | .warning-textarea { 51 | width: 600px; 52 | min-height: 20px !important; 53 | line-height: 20px; 54 | } -------------------------------------------------------------------------------- /browser/tables.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | from datetime import datetime, timedelta, timezone 3 | 4 | from django.utils.html import format_html 5 | from django.db.models import F, QuerySet 6 | 7 | import django_tables2 as tables 8 | 9 | from .models import Sponsortime, Vipuser 10 | from .columns import LengthColumn 11 | 12 | 13 | class SponsortimeTable(tables.Table): 14 | videoid = tables.TemplateColumn('{{ value }}' 15 | '' 16 | 'YT', verbose_name='VideoID') 17 | uuid = tables.TemplateColumn('' 18 | '' 19 | '🔗', verbose_name='UUID') 20 | userid = tables.TemplateColumn('' 21 | '' 22 | '🔗', 23 | verbose_name='UserID', accessor='user_id') 24 | username = tables.TemplateColumn('{% if value %}' 25 | '' 26 | '' 27 | '🔗' 28 | '{% else %}—{% endif %}', accessor='user__username') 29 | length = LengthColumn(initial_sort_descending=True) 30 | votes = tables.Column(initial_sort_descending=True) 31 | views = tables.Column(initial_sort_descending=True) 32 | actiontype = tables.Column(verbose_name='Action') 33 | 34 | class Meta: # noqa 35 | model = Sponsortime 36 | exclude = ('locked', 'incorrectvotes', 'user', 'service', 'videoduration', 'reputation', 'hashedvideoid', 37 | 'useragent', 'description') 38 | sequence = ('timesubmitted', 'videoid', 'starttime', 'endtime', 'length', 'votes', 'views', 'category', 39 | 'actiontype', 'hidden', 'shadowhidden', 'uuid', 'username') 40 | 41 | @staticmethod 42 | def render_timesubmitted(value: float) -> str: 43 | return datetime.fromtimestamp(value / 1000., tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S') 44 | 45 | @staticmethod 46 | def render_time(value: float) -> str: 47 | if value < 0: 48 | time = f'-{str(timedelta(seconds=-value))}' 49 | else: 50 | time = str(timedelta(seconds=value)) 51 | try: 52 | time, decimal = time.split('.') 53 | decimal = decimal.rstrip('0') 54 | if len(decimal) > 3: 55 | return format_html('{}.{}', time, decimal) 56 | return f'{time}.{decimal}' 57 | except ValueError: 58 | return time 59 | 60 | @staticmethod 61 | def render_starttime(value: float) -> str: 62 | return SponsortimeTable.render_time(value) 63 | 64 | @staticmethod 65 | def render_endtime(value: float) -> str: 66 | return SponsortimeTable.render_time(value) 67 | 68 | @staticmethod 69 | def render_votes(value: int, record) -> str: 70 | hidden = '' 71 | locked = '' 72 | if record.locked == 1: 73 | locked = '🔒' 74 | if value <= -2: 75 | hidden = '' 76 | if Vipuser.objects.filter(userid=record.user_id).exists(): 77 | return format_html(f'{value}{hidden}{locked}👑') 78 | return format_html(f'{value}{hidden}{locked}') 79 | 80 | @staticmethod 81 | def render_actiontype(value: str) -> str: 82 | if value == 'skip': 83 | return format_html('⏭️') 84 | if value == 'mute': 85 | return format_html('🔇') 86 | if value == 'full': 87 | return format_html('♾️') 88 | if value == 'poi': 89 | return format_html('✨️') 90 | if value == 'chapter': 91 | return format_html('🏷️') 92 | return '—' 93 | 94 | @staticmethod 95 | def render_hidden(value: int) -> str: 96 | if value == 1: 97 | return format_html('') 98 | return '—' 99 | 100 | @staticmethod 101 | def render_shadowhidden(value: int) -> str: 102 | if value >= 1: 103 | return format_html('') 104 | return '—' 105 | 106 | @staticmethod 107 | def render_category(value: str, record) -> str: 108 | if record.description: 109 | return format_html('{}', record.description, value) 110 | return value 111 | 112 | @staticmethod 113 | def order_username(queryset: QuerySet, is_descending: bool) -> (QuerySet, bool): 114 | if is_descending: 115 | queryset = queryset.select_related('user').order_by(F('user__username').desc(nulls_last=True)) 116 | else: 117 | queryset = queryset.select_related('user').order_by(F('user__username').asc(nulls_last=True)) 118 | return queryset, True 119 | 120 | 121 | class VideoTable(SponsortimeTable): 122 | class Meta: # noqa 123 | exclude = ('videoid',) 124 | sequence = ('timesubmitted', 'starttime', 'endtime', 'length', 'votes', 'views', 'category', 'shadowhidden', 125 | 'uuid', 'username') 126 | 127 | 128 | class UsernameTable(SponsortimeTable): 129 | class Meta: # noqa 130 | exclude = ('username',) 131 | sequence = ('timesubmitted', 'videoid', 'starttime', 'endtime', 'length', 'votes', 'views', 'category', 132 | 'shadowhidden', 'uuid') 133 | 134 | 135 | class UserIDTable(SponsortimeTable): 136 | class Meta: # noqa 137 | exclude = ('username', 'userid') 138 | sequence = ('timesubmitted', 'videoid', 'starttime', 'endtime', 'length', 'votes', 'views', 'category', 139 | 'shadowhidden') 140 | -------------------------------------------------------------------------------- /browser/templates/browser/404.html: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: AGPL-3.0-or-later #} 2 | {% extends "browser/index.html" %} 3 | {% load django_bootstrap5 %} 4 | {% block head %} 5 | Not Found | SB Browser 6 | {% endblock head %} 7 | {% block body %} 8 | {{ block.super }} 9 |

Whatever you just tried to look for couldn't be found

10 | {% endblock body %} -------------------------------------------------------------------------------- /browser/templates/browser/base.html: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: AGPL-3.0-or-later #} 2 | {% load static %} 3 | {% load django_bootstrap5 %} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% block head %} 13 | {% endblock head %} 14 | 15 | 16 |
17 |
18 |
19 | 40 |
41 |
42 | {% block body %} 43 | {% endblock %} 44 |
45 |
46 | Last update: {{ updated }} All times UTC 47 |
48 |
49 | SBbrowser © {% now 'Y' %} Lartza, licensed under AGPLv3. Uses SponsorBlock data licensed under CC BY-NC-SA 4.0 from https://sponsor.ajay.app/. 50 |
51 |
52 |
53 | {% bootstrap_javascript %} 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /browser/templates/browser/index.html: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: AGPL-3.0-or-later #} 2 | {% extends "browser/base.html" %} 3 | {% load django_bootstrap5 %} 4 | {% load django_tables2 %} 5 | {% load i18n %} 6 | {% load l10n %} 7 | {% block head %} 8 | SB Browser 9 | {% endblock head %} 10 | {% block body %} 11 |
12 |
13 |
14 |
15 | 16 | 17 |
18 | 19 |
20 |
21 |
22 |
23 |
24 | 25 | 26 |
27 | 28 |
29 |
30 |
31 |
32 |
33 | 34 | 35 |
36 | 37 |
38 |
39 |
40 |
41 |
42 | 43 | 44 |
45 | 46 |
47 |
48 |
49 | {% block table %} 50 |
51 |
52 | 53 | {% block table.thead %} 54 | {% if table.show_header %} 55 | 56 | 57 | {% for column in table.columns %} 58 | 61 | {% endfor %} 62 | 63 | 64 | {% endif %} 65 | {% endblock table.thead %} 66 | {% block table.tbody %} 67 | 68 | {% for row in table.paginated_rows %} 69 | {% block table.tbody.row %} 70 | 71 | {% for column, cell in row.items %} 72 | 73 | {% endfor %} 74 | 75 | {% endblock table.tbody.row %} 76 | {% empty %} 77 | {% if table.empty_text %} 78 | {% block table.tbody.empty_text %} 79 | 80 | {% endblock table.tbody.empty_text %} 81 | {% endif %} 82 | {% endfor %} 83 | 84 | {% endblock table.tbody %} 85 | {% block table.tfoot %} 86 | {% if table.has_footer %} 87 | 88 | 89 | {% for column in table.columns %} 90 | 91 | {% endfor %} 92 | 93 | 94 | {% endif %} 95 | {% endblock table.tfoot %} 96 |
59 | {{ column.header }} 60 |
{% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}
{{ table.empty_text }}
{{ column.footer }}
97 |
98 |
99 | {% endblock table %} 100 | {% endblock body %} 101 | -------------------------------------------------------------------------------- /browser/templates/browser/snippets/userid_details.html: -------------------------------------------------------------------------------- 1 |
    2 |
  • Username:
    {% if username != "—" %}{{ username }}{% else %}{{ username }}{% endif %}{% if vip %}👑{% endif %}
  • 3 |
  • Submissions:
    {{ user_submissions }}
  • 4 |
  • Ignored submissions:
    {{ user_ignored }} ({{ percent_ignored }}%) + {{ user_hidden }} ❓
  • 5 |
  • Views:
    {{ views }}
  • 6 |
  • Ignored views:
    {{ ignored_views }} ({{ percent_ignored_views }}%)
  • 7 |
-------------------------------------------------------------------------------- /browser/templates/browser/snippets/videodetails.html: -------------------------------------------------------------------------------- 1 |
  • Submissions: {{ submissions }}
  • 2 |
  • Ignored: {{ ignored }} + {{ hidden }} ❓
  • 3 |
  • Locked skips: {% if lockcategories_skip %} 4 | {% for lock in lockcategories_skip %} 5 | {{ lock.category }} 6 | {% endfor %} 7 | {% else %} 8 | 9 | {% endif %} 10 |
  • 11 |
  • Locked mutes: {% if lockcategories_mute %} 12 | {% for lock in lockcategories_mute %} 13 | {{ lock.category }} 14 | {% endfor %} 15 | {% else %} 16 | 17 | {% endif %}
  • 18 |
  • Locked full: {% if lockcategories_full %} 19 | {% for lock in lockcategories_full %} 20 | {{ lock.category }} 21 | {% endfor %} 22 | {% else %} 23 | 24 | {% endif %} 25 |
  • -------------------------------------------------------------------------------- /browser/templates/browser/table.html: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: AGPL-3.0-or-later #} 2 | {% load django_bootstrap5 %} 3 | {% load django_tables2 %} 4 | {% load i18n %} 5 | {% load l10n %} 6 | {% if filter %} 7 |
    8 | {% bootstrap_form filter.form show_label=False %} 9 |
    10 | {% bootstrap_button 'Clear' button_class='btn-secondary' button_type='button' extra_classes='formreset' %} 11 | {% bootstrap_button 'Filter' %} 12 |
    13 |
    14 | {% endif %} 15 | {% block table %} 16 |
    17 |
    18 | 19 | {% block table.thead %} 20 | {% if table.show_header %} 21 | 22 | 23 | {% for column in table.columns %} 24 | 35 | {% endfor %} 36 | 37 | 38 | {% endif %} 39 | {% endblock table.thead %} 40 | {% block table.tbody %} 41 | 42 | {% for row in table.paginated_rows %} 43 | {% block table.tbody.row %} 44 | 45 | {% for column, cell in row.items %} 46 | 47 | {% endfor %} 48 | 49 | {% endblock table.tbody.row %} 50 | {% empty %} 51 | {% if table.empty_text %} 52 | {% block table.tbody.empty_text %} 53 | 54 | {% endblock table.tbody.empty_text %} 55 | {% endif %} 56 | {% endfor %} 57 | 58 | {% endblock table.tbody %} 59 | {% block table.tfoot %} 60 | {% if table.has_footer %} 61 | 62 | 63 | {% for column in table.columns %} 64 | 65 | {% endfor %} 66 | 67 | 68 | {% endif %} 69 | {% endblock table.tfoot %} 70 |
    25 | {% if column.orderable %} 26 | {% if table.page.has_next %} 27 | {{ column.header }} 28 | {% else %} 29 | {{ column.header }} 30 | {% endif %} 31 | {% else %} 32 | {{ column.header }} 33 | {% endif %} 34 |
    {% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}
    {{ table.empty_text }}
    {{ column.footer }}
    71 |
    72 |
    73 | {% endblock table %} 74 | 75 |
    76 |
    77 | {% block pagination %} 78 |
    79 | {% if table.page and table.paginator.num_pages > 1 %} 80 | 115 |
    116 | {% endif %} 117 | {% endblock pagination %} 118 |
    Query results: {{ table.rows|length }}
    119 |
    -------------------------------------------------------------------------------- /browser/templates/browser/userid.html: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: AGPL-3.0-or-later #} 2 | {% extends "browser/base.html" %} 3 | {% block head %} 4 | UserID | SB Browser 5 | {% endblock head %} 6 | {% block body %} 7 |
    8 |
    9 | {% include 'browser/snippets/userid_details.html' %} 10 |
    11 | {% if warnings %} 12 |
    13 |
      14 |
    • Active warnings:
    • 15 | {% for warning in warnings %} 16 |
    • 17 | {% endfor %} 18 |
    19 |
    20 | {% endif %} 21 |
    22 | {% include "browser/table.html" %} 23 | {% endblock body %} -------------------------------------------------------------------------------- /browser/templates/browser/username.html: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: AGPL-3.0-or-later #} 2 | {% extends "browser/base.html" %} 3 | {% load browser_extras %} 4 | {% block head %} 5 | Username | SB Browser 6 | {% endblock head %} 7 | {% block body %} 8 |
    9 |
    10 |
      11 |
    • Username:
      {{ username }}
    • 12 |
    • Unique users:
      {{ uniques_count }}
    • 13 |
    • Submissions:
      {{ user_submissions }}
    • 14 |
    • Ignored submissions:
      {{ user_ignored }} ({{ percent_ignored }}%) + {{ user_hidden }} ❓
    • 15 |
    • Views:
      {{ views }}
    • 16 |
    • Ignored views:
      {{ ignored_views }} ({{ percent_ignored_views }}%)
    • 17 |
    18 |
    19 |
    20 |
      21 | {% for userid in uniques %} 22 | {% is_vip userid as vip %} 23 |
    • {{ userid }}{% if vip %}👑{% endif %}
    • 24 | {% endfor %} 25 |
    26 |
    27 |
    28 | {% include "browser/table.html" %} 29 | {% endblock body %} -------------------------------------------------------------------------------- /browser/templates/browser/uuid.html: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: AGPL-3.0-or-later #} 2 | {% extends "browser/base.html" %} 3 | {% block head %} 4 | {{ videoid }} | SB Browser 5 | {% endblock head %} 6 | {% block body %} 7 |
    8 |
    9 |
    10 | 11 |
    12 |
    13 |
    14 |
    15 |
      16 |
    • UUID: {{ uuid.uuid }}
    • 17 |
    • UserID: {{ uuid.user_id }}{% if vip %}👑{% endif %}
    • 18 |
    • Submitted: {{ submitted }}
    • 19 |
    • {{ starttime }}–{{ endtime }} ({{ length }}) {{ actiontype }} {{ uuid.category }}
    • 20 | {% if uuid.category == 'chapter' %}
    • Description: {% if uuid.description %}{{ uuid.description }}{% else %}—{% endif %}
    • {% endif %} 21 |
    • Votes: {{ votes }} Views: {{ uuid.views }}
    • 22 |
    • Video duration: {{ duration }} Hidden: {{ uuid_hidden }} Shadowhidden: {{ shadowhidden }}
    • 23 |
    • User Agent: {% if uuid.useragent %}{{ uuid.useragent }}{% else %}—{% endif %}
    • 24 |
    25 |
    26 |
    27 | {% include 'browser/snippets/userid_details.html' %} 28 |
    29 |
    30 |
    31 |
    32 |
    33 | 37 |
    38 |
    39 | {% include "browser/table.html" %} 40 | {% endblock body %} -------------------------------------------------------------------------------- /browser/templates/browser/video.html: -------------------------------------------------------------------------------- 1 | {# SPDX-License-Identifier: AGPL-3.0-or-later #} 2 | {% extends "browser/base.html" %} 3 | {% load django_bootstrap5 %} 4 | {% block head %} 5 | {{ videoid }} | SB Browser 6 | {% endblock head %} 7 | {% block body %} 8 |
    9 |
    10 |
    11 | 12 |
    13 |
    14 |
    15 | {% if not submissions %} 16 | {% bootstrap_alert 'No submissions exist for this video, check that the Video ID is correct and when the database was last updated.' dismissible=False extra_classes='mb-1' %} 17 | {% endif %} 18 |
      19 | {% include "browser/snippets/videodetails.html" %} 20 |
    • YouTube 🔗
    • 21 |
    22 |
    23 |
    24 | {% include "browser/table.html" %} 25 | {% endblock body %} -------------------------------------------------------------------------------- /browser/templates/django_filters/widgets/multiwidget.html: -------------------------------------------------------------------------------- 1 | {% for widget in widget.subwidgets %}{% spaceless %}{% include widget.template_name %}{% if forloop.first %}{% endif %}{% endspaceless %}{% endfor %} -------------------------------------------------------------------------------- /browser/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lartza/SBbrowser/4a067115dc7def4f811d9a625a22a89b20077549/browser/templatetags/__init__.py -------------------------------------------------------------------------------- /browser/templatetags/browser_extras.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from browser.models import Vipuser 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.simple_tag 9 | def is_vip(userid) -> bool: 10 | return Vipuser.objects.filter(userid=userid).exists() 11 | -------------------------------------------------------------------------------- /browser/tests.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | # from django.test import TestCase 3 | 4 | # Create your tests here. 5 | -------------------------------------------------------------------------------- /browser/urls.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | from django.urls import path 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path('', views.FilteredSponsortimeListView.as_view(), name='index'), 8 | path('video//', views.FilteredVideoListView.as_view(), name='video'), 9 | path('userid//', views.FilteredUserIDListView.as_view(), name='userid'), 10 | path('username//', views.FilteredUsernameListView.as_view(), name='username'), 11 | path('uuid//', views.FilteredUUIDListView.as_view(), name='uuid'), 12 | ] 13 | -------------------------------------------------------------------------------- /browser/views.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-or-later 2 | import datetime 3 | from typing import Dict, Any 4 | from urllib.parse import urlparse, parse_qs 5 | from math import ceil, floor 6 | 7 | from dateutil.parser import isoparse 8 | import timeago 9 | 10 | from django.http import HttpResponseRedirect, Http404 11 | from django.shortcuts import render 12 | from django.urls import reverse 13 | from django.db.models import Sum, QuerySet, Q 14 | from django_filters.views import FilterView 15 | from django_tables2.views import SingleTableMixin 16 | from django_tables2 import SingleTableView 17 | 18 | from .models import Config, Username, Lockcategory, Sponsortime, Vipuser, Warnings 19 | from .tables import SponsortimeTable, VideoTable, UsernameTable, UserIDTable 20 | from .filters import VideoFilter, UsernameFilter, UserIDFilter 21 | 22 | 23 | def updated() -> str: 24 | date = isoparse(Config.objects.get(key='updated').value) 25 | now = datetime.datetime.now(tz=datetime.timezone.utc) 26 | return f'{date.strftime("%Y-%m-%d %H:%M:%S")} ({timeago.format(date, now)})' 27 | 28 | 29 | def get_yt_video_id(url): 30 | """Returns the Video ID extracted from the given YouTube URL.""" 31 | 32 | if not url.startswith(('http://', 'https://')): 33 | url = 'https://' + url 34 | 35 | query = urlparse(url) 36 | 37 | if query.hostname is None: 38 | raise ValueError('No hostname found') 39 | 40 | if 'youtube.com' in query.hostname: 41 | if query.path == '/watch': 42 | return parse_qs(query.query).get('v', [None])[0] 43 | if query.path.startswith(('/embed/', '/v/')): 44 | return query.path.split('/')[2] 45 | elif 'youtu.be' in query.hostname: 46 | return query.path.lstrip('/') 47 | 48 | raise ValueError('Not a YouTube URL') 49 | 50 | 51 | def populate_context(context, filter_args): 52 | context['user_submissions'] = Sponsortime.objects.filter(**filter_args).count() 53 | context['user_ignored'] = Sponsortime.objects.filter(**filter_args).filter(votes__lte=-2).count() 54 | context['user_hidden'] = Sponsortime.objects.filter(**filter_args).filter(votes__gte=-1).filter( 55 | Q(hidden=1) | Q(shadowhidden__gte=1)).count() 56 | if context['user_submissions'] != 0: 57 | context['percent_ignored'] = round(context['user_ignored'] / context['user_submissions'] * 100, 1) 58 | else: 59 | context['percent_ignored'] = 0.0 60 | context['views'] = Sponsortime.objects.filter(**filter_args).aggregate(Sum('views'))['views__sum'] 61 | context['ignored_views'] = Sponsortime.objects.filter(**filter_args).filter(votes__lte=-2).aggregate(Sum('views'))[ 62 | 'views__sum'] 63 | if context['ignored_views'] is None or context['views'] == 0: 64 | context['ignored_views'] = 0 65 | context['percent_ignored_views'] = 0.0 66 | else: 67 | context['percent_ignored_views'] = round(context['ignored_views'] / context['views'] * 100, 1) 68 | context['updated'] = updated() 69 | 70 | 71 | def populate_context_video_details(context, videoid): 72 | context['videoid'] = videoid 73 | context['submissions'] = Sponsortime.objects.filter(videoid=videoid).count() 74 | context['ignored'] = Sponsortime.objects.filter(videoid=videoid).filter(votes__lte=-2).count() 75 | context['hidden'] = Sponsortime.objects.filter(videoid=videoid).filter(votes__gte=-1).filter( 76 | Q(hidden=1) | Q(shadowhidden__gte=1)).count() 77 | 78 | context['lockcategories_skip'] = context['lockcategories_mute'] = context['lockcategories_full'] = None 79 | lockcategories = Lockcategory.objects.filter(videoid=videoid) 80 | lockcategories_skip = lockcategories.filter(actiontype='skip') 81 | lockcategories_mute = lockcategories.filter(actiontype='mute') 82 | lockcategories_full = lockcategories.filter(actiontype='full') 83 | if lockcategories_skip: 84 | context['lockcategories_skip'] = lockcategories_skip 85 | if lockcategories_mute: 86 | context['lockcategories_mute'] = lockcategories_mute 87 | if lockcategories_full: 88 | context['lockcategories_full'] = lockcategories_full 89 | 90 | 91 | class FilteredSponsortimeListView(SingleTableView): 92 | table_class = SponsortimeTable 93 | template_name = 'browser/index.html' 94 | 95 | def get_queryset(self) -> list: 96 | qs = Sponsortime.objects.order_by('-timesubmitted')[:10] 97 | return list(qs) 98 | 99 | def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: 100 | context = super().get_context_data(**kwargs) 101 | 102 | context['updated'] = updated() 103 | return context 104 | 105 | def get(self, request, *args, **kwargs): 106 | videoid = request.GET.get('videoid') 107 | if videoid: 108 | videoid = get_yt_video_id(videoid) if len(videoid) > 12 else videoid 109 | return HttpResponseRedirect(reverse('video', args=[videoid])) 110 | username = request.GET.get('username') 111 | if username: 112 | return HttpResponseRedirect(reverse('username', args=[username])) 113 | userid = request.GET.get('userid') 114 | if userid: 115 | return HttpResponseRedirect(reverse('userid', args=[userid])) 116 | uuid = request.GET.get('uuid') 117 | if uuid: 118 | return HttpResponseRedirect(reverse('uuid', args=[uuid])) 119 | return super().get(request, *args, **kwargs) 120 | 121 | 122 | class FilteredVideoListView(SingleTableMixin, FilterView): 123 | def __init__(self): 124 | super().__init__() 125 | self.videoid = None 126 | 127 | def get_queryset(self) -> QuerySet: 128 | self.videoid = self.kwargs['videoid'] 129 | return Sponsortime.objects.filter(videoid=self.videoid).order_by('-timesubmitted') 130 | 131 | def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: 132 | 133 | context = super().get_context_data(**kwargs) 134 | 135 | populate_context_video_details(context, self.videoid) 136 | context['updated'] = updated() 137 | return context 138 | 139 | table_class = VideoTable 140 | template_name = 'browser/video.html' 141 | filterset_class = VideoFilter 142 | 143 | 144 | class FilteredUsernameListView(SingleTableMixin, FilterView): 145 | def __init__(self): 146 | super().__init__() 147 | self.username = None 148 | 149 | def get_queryset(self) -> QuerySet: 150 | self.username = self.kwargs['username'] 151 | query = Sponsortime.objects.filter(user__username=self.username).order_by('-timesubmitted') 152 | if not query.exists(): 153 | raise Http404 154 | return query 155 | 156 | def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: 157 | context = super().get_context_data(**kwargs) 158 | 159 | filter_args = {'user__username': self.username} 160 | 161 | context['username'] = self.username 162 | context['uniques'] = Username.objects.filter(username=self.username) 163 | context['uniques_count'] = context['uniques'].count() 164 | 165 | populate_context(context, filter_args) 166 | 167 | return context 168 | 169 | table_class = UsernameTable 170 | template_name = 'browser/username.html' 171 | filterset_class = UsernameFilter 172 | 173 | 174 | class FilteredUserIDListView(SingleTableMixin, FilterView): 175 | def __init__(self): 176 | super().__init__() 177 | self.userid = None 178 | 179 | def get_queryset(self) -> QuerySet: 180 | self.userid = self.kwargs['userid'] 181 | query = Sponsortime.objects.filter(user=self.userid).order_by('-timesubmitted') 182 | if not query.exists(): 183 | raise Http404 184 | return query 185 | 186 | def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: 187 | context = super().get_context_data(**kwargs) 188 | 189 | filter_args = {'user': self.userid} 190 | 191 | context['userid'] = self.userid 192 | try: 193 | context['username'] = Username.objects.get(userid=self.userid).username 194 | except Username.DoesNotExist: 195 | context['username'] = '—' 196 | context['vip'] = Vipuser.objects.filter(userid=self.userid).exists() 197 | context['warnings'] = Warnings.objects.filter(userid=self.userid, enabled=1).values_list('reason', flat=True) 198 | 199 | populate_context(context, filter_args) 200 | 201 | return context 202 | 203 | table_class = UserIDTable 204 | template_name = 'browser/userid.html' 205 | filterset_class = UserIDFilter 206 | 207 | 208 | class FilteredUUIDListView(SingleTableMixin, FilterView): 209 | def __init__(self): 210 | super().__init__() 211 | self.videoid = None 212 | self.uuid = None 213 | 214 | def get_queryset(self) -> QuerySet: 215 | try: 216 | self.uuid = Sponsortime.objects.get(uuid=self.kwargs['uuid']) 217 | except Sponsortime.DoesNotExist as exc: 218 | raise Http404 from exc 219 | self.videoid = self.uuid.videoid 220 | return Sponsortime.objects.filter(videoid=self.videoid).order_by('-timesubmitted') 221 | 222 | def get_context_data(self, **kwargs: Dict[str, Any]) -> Dict[str, Any]: 223 | 224 | context = super().get_context_data(**kwargs) 225 | 226 | filter_args = {'user': self.uuid.user_id} 227 | try: 228 | context['username'] = self.uuid.user.username 229 | except Username.DoesNotExist: 230 | context['username'] = '—' 231 | context['vip'] = Vipuser.objects.filter(userid=self.uuid.user_id).exists() 232 | 233 | context['uuid'] = self.uuid 234 | context['submitted'] = SponsortimeTable.render_timesubmitted(self.uuid.timesubmitted) 235 | context['starttime'] = SponsortimeTable.render_starttime(self.uuid.starttime) 236 | context['endtime'] = SponsortimeTable.render_endtime(self.uuid.endtime) 237 | context['length'] = datetime.timedelta(seconds=self.uuid.length()) 238 | context['actiontype'] = SponsortimeTable.render_actiontype(self.uuid.actiontype) 239 | context['duration'] = datetime.timedelta(seconds=self.uuid.videoduration) 240 | context['uuid_hidden'] = SponsortimeTable.render_hidden(self.uuid.hidden) 241 | context['shadowhidden'] = SponsortimeTable.render_shadowhidden(self.uuid.shadowhidden) 242 | 243 | class FakeRecord: 244 | def __init__(self, locked, user_id): 245 | self.locked = locked 246 | self.user_id = user_id 247 | 248 | context['votes'] = SponsortimeTable.render_votes(self.uuid.votes, FakeRecord(self.uuid.locked, 249 | self.uuid.user_id)) 250 | context['start'] = max(floor(self.uuid.starttime) - 2, 0) 251 | context['end'] = ceil(self.uuid.endtime) + 4 252 | 253 | populate_context_video_details(context, self.videoid) 254 | populate_context(context, filter_args) 255 | 256 | return context 257 | 258 | table_class = VideoTable 259 | template_name = 'browser/uuid.html' 260 | filterset_class = VideoFilter 261 | 262 | 263 | def view_404(request, _exception): 264 | context = {'updated': updated()} 265 | return render(request, 'browser/404.html', status=404, context=context) 266 | -------------------------------------------------------------------------------- /docs/sbtools.nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name sbtools.example.com; 5 | return 301 https://$host$request_uri; 6 | 7 | access_log /var/log/nginx/sbtools.example.com_access.log; 8 | error_log /var/log/nginx/sbtools.example.com_error.log; 9 | } 10 | 11 | server { 12 | listen 443 ssl http2; 13 | listen [::]:443 ssl http2; 14 | 15 | ssl_certificate /etc/letsencrypt/live/sbtools.example.com/fullchain.pem; 16 | ssl_certificate_key /etc/letsencrypt/live/sbtools.example.com/privkey.pem; 17 | 18 | root /srv/http/sbtools; 19 | 20 | server_name sbtools.example.com; 21 | 22 | access_log /var/log/nginx/sbtools.example.com_access.log; 23 | error_log /var/log/nginx/sbtools.example.com_error.log; 24 | 25 | location / { 26 | try_files $uri @proxy_to_app; 27 | } 28 | 29 | location @proxy_to_app { 30 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 31 | proxy_set_header X-Forwarded-Proto $scheme; 32 | proxy_set_header Host $http_host; 33 | proxy_redirect off; 34 | proxy_pass http://127.0.0.1:5002; 35 | } 36 | } -------------------------------------------------------------------------------- /docs/sbtools.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description = SBtools daemon 3 | After = network.target 4 | 5 | [Service] 6 | PIDFile=/run/sponsorblock/sponsorblock.pid 7 | User=sponsorblock 8 | Group=sponsorblock 9 | RuntimeDirectory=SBtools 10 | WorkingDirectory=/srv/sponsorblock/SBtools 11 | ExecStart=/srv/sponsorblock/SBtools/venv/bin/gunicorn -b 127.0.0.1:5002 --pid /run/SBtools/sponsorblock.pid SBtools.wsgi 12 | ExecReload=/bin/kill -s HUP $MAINPID 13 | ExecStop=/bin/kill -s TERM $MAINPID 14 | PrivateTmp=true 15 | Environment="DJANGO_SETTINGS_MODULE=SBtools.settings.production" 16 | Environment="SECRET_KEY=changeme" 17 | Environment="DB_PASSWORD=changeme" 18 | Environment="STATIC_ROOT=/srv/http/sbtools/static/" 19 | 20 | [Install] 21 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | """Django's command-line utility for administrative tasks.""" 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'SBtools.settings.development') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==5.1.5 2 | django-bootstrap5==24.3 3 | django-tables2==2.7.5 4 | django-filter==24.3 5 | psycopg[c]==3.2.4 6 | python-dateutil==2.9.0.post0 7 | timeago==1.0.16 8 | redis==5.2.1 9 | hiredis==3.1.0 --------------------------------------------------------------------------------