diff --git a/.env.example b/.env.example
index 7882e6c..30f3d28 100644
--- a/.env.example
+++ b/.env.example
@@ -33,6 +33,7 @@ SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
+SESSION_COOKIE=teal_session
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100755
index 0000000..1920237
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# Fast local gate before pushing: style + static analysis.
+# Tests run in CI (they need a database); keep this loop quick.
+set -euo pipefail
+
+echo "[pre-push] Pint (style)…"
+./vendor/bin/pint --test
+
+echo "[pre-push] PHPStan (max, baseline-gated)…"
+php -d memory_limit=2G vendor/bin/phpstan analyse --no-progress
+
+echo "[pre-push] ok — pushing."
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 8367ccc..2e5ee11 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -1,13 +1,54 @@
-name: Tests
+name: CI
on:
push:
branches: [ "main" ]
pull_request:
- branches: [ "main" ]
jobs:
- laravel-tests:
+ quality:
+ name: Quality (Pint + PHPStan)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: mbstring, xml, ctype, iconv, intl, pdo_pgsql
+ coverage: none
+ tools: composer:v2
+ - name: Copy .env
+ run: php -r "file_exists('.env') || copy('.env.example', '.env');"
+ - name: Install dependencies
+ run: composer install -q --no-ansi --no-interaction --no-progress --prefer-dist
+ - name: Generate key
+ run: php artisan key:generate
+ - name: Pint (style check)
+ run: vendor/bin/pint --test
+ - name: PHPStan (max, baseline-gated)
+ run: vendor/bin/phpstan analyse --no-progress --memory-limit=2G
+
+ rector:
+ name: Rector (advisory)
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: mbstring, xml, ctype, iconv, intl, pdo_pgsql
+ coverage: none
+ tools: composer:v2
+ - name: Copy .env
+ run: php -r "file_exists('.env') || copy('.env.example', '.env');"
+ - name: Install dependencies
+ run: composer install -q --no-ansi --no-interaction --no-progress --prefer-dist
+ - name: Rector (dry-run, advisory)
+ run: vendor/bin/rector process --dry-run --no-progress-bar
+
+ tests:
+ name: Tests (Pest)
runs-on: ubuntu-latest
services:
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8d414fd
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,671 @@
+TEAL — The Essential Aggregator Library
+Copyright (C) 2026 Jonatan Jansson (dotMavriQ)
+
+This program is free software: you can redistribute it and/or modify it
+under the terms of the GNU Affero General Public License as published by the
+Free Software Foundation, either version 3 of the License, or (at your option)
+any later version. The full text follows.
+
+----------------------------------------------------------------------
+
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
index 498c2fb..8f870ab 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,32 @@ COMIC_VINE_API_KEY=your_key
Book metadata (OpenLibrary) and anime metadata (Jikan/MAL) work without API keys.
+## Development
+
+### Coding standard
+
+The codebase follows **PSR-12** (and, by extension, PSR-1 for basic style and PSR-4 for autoloading). Style is enforced with [Laravel Pint](https://laravel.com/docs/pint) using the `laravel` preset — a PSR-12 superset that adds Laravel idioms — configured in `pint.json`. Every PHP file declares `strict_types=1`.
+
+```bash
+composer lint # check formatting (pint --test), no changes
+./vendor/bin/pint # apply formatting
+```
+
+### Static analysis
+
+[PHPStan](https://phpstan.org/) via [Larastan](https://github.com/larastan/larastan) runs at `level: max`, configured in `phpstan.neon`. Existing findings are captured in `phpstan-baseline.neon`; new code must not add to the baseline. Declared-type coverage floors (return/param/property) ratchet up over time and cannot regress.
+
+```bash
+composer stan # phpstan analyse
+composer quality # lint + stan
+```
+
+A `pre-push` hook (`.githooks/pre-push`, enabled via `composer hooks`) runs Pint and PHPStan before every push. Tests run in CI.
+
+```bash
+composer test # Pest suite (needs the teal_test database)
+```
+
## License
MIT
diff --git a/app/Console/Commands/EnrichMovies.php b/app/Console/Commands/EnrichMovies.php
index 21f8023..ad0cb36 100644
--- a/app/Console/Commands/EnrichMovies.php
+++ b/app/Console/Commands/EnrichMovies.php
@@ -1,45 +1,57 @@
option('limit');
-
+
$movies = Movie::whereNotNull('imdb_id')
- ->where('status', \App\Enums\WatchingStatus::Watchlist->value)
- ->where(function($q) {
+ ->where('status', WatchingStatus::Watchlist->value)
+ ->where(function ($q): void {
$q->whereNull('poster_url')
- ->orWhereNull('description');
+ ->orWhereNull('description');
})
->take($limit)
->get();
if ($movies->isEmpty()) {
- $this->info("No movies need enrichment.");
- return;
+ $this->info('No movies need enrichment.');
+
+ return self::SUCCESS;
}
$this->info("Enriching {$movies->count()} items...");
foreach ($movies as $movie) {
- $this->line("Processing: {$movie->title} ({$movie->imdb_id})");
-
- $data = $tmdb->findByImdbId($movie->imdb_id);
-
- if (!$data) {
- $this->warn(" TMDB miss, trying Trakt...");
- $data = $trakt->findByImdbId($movie->imdb_id);
+ $imdbId = $movie->imdb_id;
+
+ if (! is_string($imdbId)) {
+ continue;
+ }
+
+ $this->line("Processing: {$movie->title} ({$imdbId})");
+
+ $data = $tmdb->findByImdbId($imdbId);
+
+ if (! $data) {
+ $this->warn(' TMDB miss, trying Trakt...');
+ $data = $trakt->findByImdbId($imdbId);
}
if ($data) {
@@ -53,30 +65,30 @@ public function handle(TmdbService $tmdb, TraktService $trakt)
'metadata_fetched_at' => now(),
]);
- if (!empty($updates)) {
- $movie->update($updates);
- $this->info(" Updated metadata.");
-
- // If this is a show name, propagate the poster to episodes
- if (($movie->title_type === 'TV Series' || $movie->title_type === 'TV Mini Series') && $movie->poster_url) {
- Movie::propagateShowPoster(
- $movie->user_id,
- $movie->title,
- $movie->title,
- $movie->poster_url,
- $movie->title
- );
- }
+ $movie->update($updates);
+ $this->info(' Updated metadata.');
+
+ // If this is a show name, propagate the poster to episodes
+ if (($movie->title_type === 'TV Series' || $movie->title_type === 'TV Mini Series') && $movie->poster_url) {
+ Movie::propagateShowPoster(
+ $movie->user_id,
+ $movie->title,
+ $movie->title,
+ $movie->poster_url,
+ $movie->title
+ );
}
} else {
- $this->error(" No metadata found for {$movie->imdb_id}");
+ $this->error(" No metadata found for {$imdbId}");
$movie->update(['metadata_fetched_at' => now()]);
}
// Simple rate limit protection (4 requests per second)
- usleep(250000);
+ Sleep::usleep(250000);
}
- $this->info("Enrichment complete.");
+ $this->info('Enrichment complete.');
+
+ return self::SUCCESS;
}
}
diff --git a/app/Console/Commands/FixBookStatuses.php b/app/Console/Commands/FixBookStatuses.php
index a774c00..1bf3c7f 100644
--- a/app/Console/Commands/FixBookStatuses.php
+++ b/app/Console/Commands/FixBookStatuses.php
@@ -15,6 +15,7 @@ class FixBookStatuses extends Command
protected $description = 'Fix book statuses and optionally extract shelves from the shelves field';
+ /** @var list */
private array $statusKeywords = ['read', 'to-read', 'currently-reading', 'want-to-read', 'reading'];
public function handle(): int
@@ -27,11 +28,11 @@ public function handle(): int
$total = Book::whereNotNull('shelves')->count();
$this->info("Processing {$total} books with shelf data...");
- Book::whereNotNull('shelves')->with('user')->each(function (Book $book) use ($extractShelves, &$statusUpdated, &$shelvesCreated, &$booksWithShelves) {
- $shelfParts = array_map('trim', explode(',', $book->shelves ?? ''));
+ Book::whereNotNull('shelves')->with('user')->each(function (Book $book) use ($extractShelves, &$statusUpdated, &$shelvesCreated, &$booksWithShelves): void {
+ $shelfParts = array_map(trim(...), explode(',', $book->shelves ?? ''));
// First part is always status
- $statusPart = strtolower($shelfParts[0] ?? '');
+ $statusPart = strtolower($shelfParts[0]);
// Determine correct status
if (str_contains($statusPart, 'to-read') || str_contains($statusPart, 'want')) {
@@ -52,13 +53,16 @@ public function handle(): int
// Extract custom shelves (everything after the first part that isn't a status keyword)
if ($extractShelves && count($shelfParts) > 1) {
$customShelves = [];
+ $counter = count($shelfParts);
- for ($i = 1; $i < count($shelfParts); $i++) {
+ for ($i = 1; $i < $counter; $i++) {
$shelfName = trim($shelfParts[$i]);
$shelfLower = strtolower($shelfName);
-
// Skip if it's a status keyword
- if (empty($shelfName) || in_array($shelfLower, $this->statusKeywords)) {
+ if (empty($shelfName)) {
+ continue;
+ }
+ if (in_array($shelfLower, $this->statusKeywords)) {
continue;
}
@@ -67,7 +71,7 @@ public function handle(): int
$shelvesCreated++;
}
- if (! empty($customShelves)) {
+ if ($customShelves !== []) {
$book->bookShelves()->syncWithoutDetaching($customShelves);
$booksWithShelves++;
}
diff --git a/app/Console/Commands/ImportGoodreads.php b/app/Console/Commands/ImportGoodreads.php
index 595d7b5..2ca6a56 100644
--- a/app/Console/Commands/ImportGoodreads.php
+++ b/app/Console/Commands/ImportGoodreads.php
@@ -4,8 +4,9 @@
namespace App\Console\Commands;
-use App\Services\JsonImportService;
use App\Models\User;
+use App\Services\JsonImportService;
+use Exception;
use Illuminate\Console\Command;
class ImportGoodreads extends Command
@@ -19,23 +20,31 @@ public function handle(): int
$userId = (int) $this->argument('user_id');
$user = User::find($userId);
- if (!$user) {
+ if (! $user) {
$this->error("User with ID {$userId} not found");
+
return 1;
}
$filePath = base_path('goodreads.txt');
- if (!file_exists($filePath)) {
+ if (! file_exists($filePath)) {
$this->error("File not found: {$filePath}");
+
return 1;
}
$this->info('Reading goodreads.txt...');
$content = file_get_contents($filePath);
+ if ($content === false) {
+ $this->error("Could not read: {$filePath}");
+
+ return 1;
+ }
+
try {
- $service = new JsonImportService();
+ $service = new JsonImportService;
$books = $service->parseJson($content);
$this->info("Parsed {$books->count()} books");
@@ -68,8 +77,9 @@ public function handle(): int
$this->info("✓ No changes needed: {$skipped} books");
return 0;
- } catch (\Exception $e) {
- $this->error('Import failed: ' . $e->getMessage());
+ } catch (Exception $e) {
+ $this->error('Import failed: '.$e->getMessage());
+
return 1;
}
}
diff --git a/app/Console/Commands/ImportImdbWatchlist.php b/app/Console/Commands/ImportImdbWatchlist.php
index 10b3e98..c3d3476 100644
--- a/app/Console/Commands/ImportImdbWatchlist.php
+++ b/app/Console/Commands/ImportImdbWatchlist.php
@@ -1,50 +1,72 @@
error("File not found: $filePath");
+
return 1;
}
$user = User::find($this->argument('user_id'));
- if (!$user) {
- $this->error("User not found.");
+ if (! $user) {
+ $this->error('User not found.');
+
return 1;
}
$this->info("Importing IMDb Watchlist for user: {$user->name}");
$file = fopen($filePath, 'r');
- $headers = fgetcsv($file);
-
+ if ($file === false) {
+ $this->error("Could not open: $filePath");
+
+ return 1;
+ }
+
+ $headerRow = fgetcsv($file);
+ if ($headerRow === false) {
+ fclose($file);
+ $this->error('CSV has no header row.');
+
+ return 1;
+ }
+ $headers = array_map(fn ($h): string => (string) $h, $headerRow);
+
$importedCount = 0;
$skippedCount = 0;
while (($row = fgetcsv($file)) !== false) {
- $data = array_combine($headers, $row);
-
- $title = $data['Title'];
- $imdbId = $data['Const'];
- $titleType = $data['Title Type'];
-
+ if (count($row) !== count($headers)) {
+ continue;
+ }
+
+ $data = array_combine($headers, array_map(fn ($v): ?string => $v ?? null, $row));
+
+ $title = $this->strOf($data['Title'] ?? null);
+ $imdbId = $this->strOf($data['Const'] ?? null);
+ $titleType = $this->strOf($data['Title Type'] ?? null);
+
// Skip duplicates
- if (Movie::where('user_id', $user->id)->where('imdb_id', $imdbId)->exists()) {
+ if ($imdbId !== '' && Movie::where('user_id', $user->id)->where('imdb_id', $imdbId)->exists()) {
$skippedCount++;
+
continue;
}
@@ -53,10 +75,10 @@ public function handle()
'title' => $title,
'imdb_id' => $imdbId,
'title_type' => $titleType,
- 'year' => !empty($data['Year']) ? (int)$data['Year'] : null,
- 'runtime_minutes' => !empty($data['Runtime (mins)']) ? (int)$data['Runtime (mins)'] : null,
- 'genres' => !empty($data['Genres']) ? $data['Genres'] : null,
- 'imdb_rating' => !empty($data['IMDb Rating']) ? $data['IMDb Rating'] : null,
+ 'year' => empty($data['Year']) ? null : (int) $data['Year'],
+ 'runtime_minutes' => empty($data['Runtime (mins)']) ? null : (int) $data['Runtime (mins)'],
+ 'genres' => empty($data['Genres']) ? null : $data['Genres'],
+ 'imdb_rating' => empty($data['IMDb Rating']) ? null : $data['IMDb Rating'],
'status' => WatchingStatus::Watchlist->value,
'date_added' => now(),
];
@@ -66,7 +88,7 @@ public function handle()
$mapped['show_name'] = $info['show'];
$mapped['season_number'] = $info['season'];
$mapped['episode_number'] = $info['episode'];
-
+
// Ensure parent show exists
$this->ensureParentShow($user, $info['show']);
}
@@ -77,16 +99,18 @@ public function handle()
fclose($file);
$this->info("Import complete. Imported: $importedCount, Skipped: $skippedCount");
+
+ return 0;
}
- private function ensureParentShow($user, $showName)
+ private function ensureParentShow(User $user, string $showName): void
{
$exists = Movie::where('user_id', $user->id)
->where('title', $showName)
->where('title_type', 'TV Series')
->exists();
- if (!$exists) {
+ if (! $exists) {
Movie::create([
'user_id' => $user->id,
'title' => $showName,
@@ -97,18 +121,27 @@ private function ensureParentShow($user, $showName)
}
}
- private function parseEpisodeTitle($title)
+ /**
+ * @return array{show: string, season: int|null, episode: int|null}
+ */
+ private function parseEpisodeTitle(string $title): array
{
// Format: "Show Name: Episode Name" or "Show Name: Episode #1.1"
if (preg_match('/^(.+?):\s+Episode\s+#(\d+)\.(\d+)$/', $title, $m)) {
- return ['show' => $m[1], 'season' => (int)$m[2], 'episode' => (int)$m[3]];
+ return ['show' => $m[1], 'season' => (int) $m[2], 'episode' => (int) $m[3]];
}
-
- if (strpos($title, ':') !== false) {
+
+ if (str_contains($title, ':')) {
$parts = explode(':', $title);
+
return ['show' => trim($parts[0]), 'season' => null, 'episode' => null];
}
return ['show' => $title, 'season' => null, 'episode' => null];
}
+
+ private function strOf(mixed $value): string
+ {
+ return is_scalar($value) ? (string) $value : '';
+ }
}
diff --git a/app/Enums/BoardGameStatus.php b/app/Enums/BoardGameStatus.php
new file mode 100644
index 0000000..8b74cc3
--- /dev/null
+++ b/app/Enums/BoardGameStatus.php
@@ -0,0 +1,36 @@
+ 'Owned',
+ self::WantToPlay => 'Want to Play',
+ self::Wishlist => 'Wishlist',
+ self::ForTrade => 'For Trade',
+ self::PreviouslyOwned => 'Previously Owned',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Owned => 'owned',
+ self::WantToPlay => 'want-to-play',
+ self::Wishlist => 'wishlist',
+ self::ForTrade => 'for-trade',
+ self::PreviouslyOwned => 'previously-owned',
+ };
+ }
+}
diff --git a/app/Enums/CollectionStatus.php b/app/Enums/CollectionStatus.php
new file mode 100644
index 0000000..b2c4d61
--- /dev/null
+++ b/app/Enums/CollectionStatus.php
@@ -0,0 +1,33 @@
+ 'Wishlist',
+ self::Listening => 'Listening',
+ self::Listened => 'Listened',
+ self::Shelved => 'Shelved',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Wishlist => 'blue',
+ self::Listening => 'yellow',
+ self::Listened => 'green',
+ self::Shelved => 'gray',
+ };
+ }
+}
diff --git a/app/Enums/ListeningStatus.php b/app/Enums/ListeningStatus.php
new file mode 100644
index 0000000..326d36a
--- /dev/null
+++ b/app/Enums/ListeningStatus.php
@@ -0,0 +1,33 @@
+ 'Want to Go',
+ self::Going => 'Going',
+ self::Attended => 'Attended',
+ self::Missed => 'Missed',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::WantToGo => 'blue',
+ self::Going => 'yellow',
+ self::Attended => 'green',
+ self::Missed => 'gray',
+ };
+ }
+}
diff --git a/app/Enums/OwnershipStatus.php b/app/Enums/OwnershipStatus.php
new file mode 100644
index 0000000..d2c7dcc
--- /dev/null
+++ b/app/Enums/OwnershipStatus.php
@@ -0,0 +1,36 @@
+ 'Owned',
+ self::PreviouslyOwned => 'Previously Owned',
+ self::Borrowed => 'Borrowed',
+ self::OnEmulator => 'On Emulator',
+ self::NotOwned => 'Not Owned',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Owned => 'green',
+ self::PreviouslyOwned => 'yellow',
+ self::Borrowed => 'blue',
+ self::OnEmulator => 'purple',
+ self::NotOwned => 'gray',
+ };
+ }
+}
diff --git a/app/Enums/PlayingStatus.php b/app/Enums/PlayingStatus.php
new file mode 100644
index 0000000..f2b3ae9
--- /dev/null
+++ b/app/Enums/PlayingStatus.php
@@ -0,0 +1,36 @@
+ 'Backlog',
+ self::Playing => 'Playing',
+ self::Shelved => 'Shelved',
+ self::Completed => 'Completed',
+ self::Mastered => 'Mastered',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Backlog => 'gray',
+ self::Playing => 'yellow',
+ self::Shelved => 'orange',
+ self::Completed => 'green',
+ self::Mastered => 'purple',
+ };
+ }
+}
diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php
index 784765e..516149e 100644
--- a/app/Http/Controllers/Auth/VerifyEmailController.php
+++ b/app/Http/Controllers/Auth/VerifyEmailController.php
@@ -1,5 +1,7 @@
cover_url && str_starts_with($book->cover_url, '/storage/')) {
+ if ($book->cover_url && str_starts_with((string) $book->cover_url, '/storage/')) {
return;
}
@@ -94,7 +97,7 @@ private function downloadFromExternalUrl(string $url, int $bookId): ?string
}
return null;
- } catch (\Exception $e) {
+ } catch (Exception $e) {
Log::debug("FetchBookCover: Error processing external URL for book {$bookId}: {$e->getMessage()}");
return null;
@@ -145,7 +148,7 @@ private function fetchImage(string $url): ?string
}
return $body;
- } catch (\Exception $e) {
+ } catch (Exception $e) {
Log::debug("FetchBookCover: Error fetching {$url}: {$e->getMessage()}");
return null;
@@ -167,7 +170,7 @@ private function storeImage(string $imageData, int $bookId): ?string
// Return the public URL path
return '/storage/'.$filename;
- } catch (\Exception $e) {
+ } catch (Exception $e) {
Log::error("FetchBookCover: Error storing image for book {$bookId}: {$e->getMessage()}");
return null;
@@ -178,11 +181,11 @@ private function optimizeImage(string $imageData, string $extension): string
{
try {
// Try to use Intervention Image v3 if available
- if (class_exists(\Intervention\Image\ImageManager::class) &&
- class_exists(\Intervention\Image\Drivers\Gd\Driver::class)) {
+ if (class_exists(ImageManager::class) &&
+ class_exists(Driver::class)) {
return $this->optimizeWithIntervention($imageData, $extension);
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
Log::debug("FetchBookCover: Intervention Image optimization failed: {$e->getMessage()}");
}
@@ -193,8 +196,8 @@ class_exists(\Intervention\Image\Drivers\Gd\Driver::class)) {
private function optimizeWithIntervention(string $imageData, string $extension): string
{
// Intervention Image v3 API
- $manager = new \Intervention\Image\ImageManager(
- new \Intervention\Image\Drivers\Gd\Driver
+ $manager = new ImageManager(
+ new Driver
);
$image = $manager->read($imageData);
@@ -202,16 +205,18 @@ private function optimizeWithIntervention(string $imageData, string $extension):
if ($image->width() > self::MAX_DIMENSION || $image->height() > self::MAX_DIMENSION) {
$image->scaleDown(self::MAX_DIMENSION, self::MAX_DIMENSION);
}
-
// Encode with compression
if ($extension === 'webp') {
return (string) $image->toWebp(self::COMPRESSION_QUALITY);
- } elseif ($extension === 'png') {
+ }
+
+ // Encode with compression
+ if ($extension === 'png') {
return (string) $image->toPng();
- } else {
- // JPEG or fallback
- return (string) $image->toJpeg(self::COMPRESSION_QUALITY);
}
+
+ // JPEG or fallback
+ return (string) $image->toJpeg(self::COMPRESSION_QUALITY);
}
private function detectImageExtension(string $data): string
@@ -234,7 +239,7 @@ private function detectImageExtension(string $data): string
}
// WebP
- if (substr($magicBytes, 0, 4) === 'RIFF' && substr($data, 8, 4) === 'WEBP') {
+ if (str_starts_with($magicBytes, 'RIFF') && substr($data, 8, 4) === 'WEBP') {
return 'webp';
}
diff --git a/app/Jobs/FetchBookMetadata.php b/app/Jobs/FetchBookMetadata.php
index 1038d69..6f63c97 100644
--- a/app/Jobs/FetchBookMetadata.php
+++ b/app/Jobs/FetchBookMetadata.php
@@ -6,10 +6,12 @@
use App\Models\Book;
use App\Services\OpenLibraryService;
+use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Sleep;
class FetchBookMetadata implements ShouldQueue
{
@@ -21,6 +23,10 @@ class FetchBookMetadata implements ShouldQueue
private const CACHE_KEY_PREFIX = 'metadata_fetch_';
+ /**
+ * @param list $bookIds
+ * @param list $sourcePriority
+ */
public function __construct(
public int $userId,
public array $bookIds,
@@ -31,7 +37,8 @@ public function __construct(
public function handle(): void
{
- $cacheKey = self::CACHE_KEY_PREFIX . $this->userId;
+ $cacheKey = self::CACHE_KEY_PREFIX.$this->userId;
+ $startedAt = now()->toIso8601String();
// Mark as running
Cache::put($cacheKey, [
@@ -40,7 +47,7 @@ public function handle(): void
'total' => count($this->bookIds),
'fetched' => 0,
'applied' => 0,
- 'started_at' => now()->toIso8601String(),
+ 'started_at' => $startedAt,
'updated_at' => now()->toIso8601String(),
], now()->addHours(2));
@@ -103,15 +110,15 @@ public function handle(): void
'total' => count($this->bookIds),
'fetched' => $fetched,
'applied' => $applied,
- 'started_at' => Cache::get($cacheKey)['started_at'] ?? now()->toIso8601String(),
+ 'started_at' => $startedAt,
'updated_at' => now()->toIso8601String(),
], now()->addHours(2));
// Small delay to avoid hammering the API
- usleep(250000); // 250ms
+ Sleep::usleep(250000); // 250ms
- } catch (\Exception $e) {
- Log::warning("FetchBookMetadata: Error fetching book {$bookId}: " . $e->getMessage());
+ } catch (Exception $e) {
+ Log::warning("FetchBookMetadata: Error fetching book {$bookId}: ".$e->getMessage());
}
}
@@ -122,7 +129,7 @@ public function handle(): void
'total' => count($this->bookIds),
'fetched' => $fetched,
'applied' => $applied,
- 'started_at' => Cache::get($cacheKey)['started_at'] ?? now()->toIso8601String(),
+ 'started_at' => $startedAt,
'completed_at' => now()->toIso8601String(),
'updated_at' => now()->toIso8601String(),
], now()->addHours(2));
@@ -130,14 +137,19 @@ public function handle(): void
Log::info("FetchBookMetadata: Completed for user {$this->userId}. Fetched: {$fetched}, Applied: {$applied}");
}
+ /**
+ * @return array|null
+ */
public static function getStatus(int $userId): ?array
{
- return Cache::get(self::CACHE_KEY_PREFIX . $userId);
+ $status = Cache::get(self::CACHE_KEY_PREFIX.$userId);
+
+ return is_array($status) ? $status : null;
}
public static function clearStatus(int $userId): void
{
- Cache::forget(self::CACHE_KEY_PREFIX . $userId);
+ Cache::forget(self::CACHE_KEY_PREFIX.$userId);
}
public static function isRunning(int $userId): bool
diff --git a/app/Jobs/FetchMovieMetadata.php b/app/Jobs/FetchMovieMetadata.php
index 99aeb2b..efd3dfc 100644
--- a/app/Jobs/FetchMovieMetadata.php
+++ b/app/Jobs/FetchMovieMetadata.php
@@ -7,10 +7,12 @@
use App\Models\Movie;
use App\Services\TmdbService;
use App\Services\TraktService;
+use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Sleep;
class FetchMovieMetadata implements ShouldQueue
{
@@ -22,6 +24,10 @@ class FetchMovieMetadata implements ShouldQueue
private const CACHE_KEY_PREFIX = 'movie_metadata_fetch_';
+ /**
+ * @param list $movieIds
+ * @param list $sourcePriority
+ */
public function __construct(
public int $userId,
public array $movieIds,
@@ -30,7 +36,8 @@ public function __construct(
public function handle(): void
{
- $cacheKey = self::CACHE_KEY_PREFIX . $this->userId;
+ $cacheKey = self::CACHE_KEY_PREFIX.$this->userId;
+ $startedAt = now()->toIso8601String();
Cache::put($cacheKey, [
'status' => 'running',
@@ -38,7 +45,7 @@ public function handle(): void
'total' => count($this->movieIds),
'fetched' => 0,
'applied' => 0,
- 'started_at' => now()->toIso8601String(),
+ 'started_at' => $startedAt,
'updated_at' => now()->toIso8601String(),
], now()->addHours(2));
@@ -101,8 +108,8 @@ public function handle(): void
} else {
// Fallback: search TV shows by show_name or title prefix
$showName = $movie->show_name;
- if (empty($showName) && str_contains($movie->title, ':')) {
- $showName = trim(explode(':', $movie->title, 2)[0]);
+ if (empty($showName) && str_contains((string) $movie->title, ':')) {
+ $showName = trim(explode(':', (string) $movie->title, 2)[0]);
}
if (! empty($showName)) {
@@ -122,11 +129,13 @@ public function handle(): void
}
// Propagate show poster + show_name to siblings missing them
- $posterToPropagate = $updateData['poster_url'] ?? $movie->poster_url;
- $showNameToPropagate = $updateData['show_name'] ?? $movie->show_name;
- if ($posterToPropagate || $showNameToPropagate) {
- $titlePrefix = str_contains($movie->title, ':')
- ? trim(explode(':', $movie->title, 2)[0])
+ $posterRaw = $updateData['poster_url'] ?? $movie->poster_url;
+ $posterToPropagate = is_string($posterRaw) ? $posterRaw : null;
+ $showNameRaw = $updateData['show_name'] ?? $movie->show_name;
+ $showNameToPropagate = is_string($showNameRaw) ? $showNameRaw : null;
+ if ($posterToPropagate !== null || $showNameToPropagate !== null) {
+ $titlePrefix = str_contains((string) $movie->title, ':')
+ ? trim(explode(':', (string) $movie->title, 2)[0])
: null;
Movie::propagateShowPoster(
$this->userId,
@@ -199,10 +208,11 @@ public function handle(): void
}
// If this is a TV show, propagate poster to its episodes
- $showPoster = $updateData['poster_url'] ?? $movie->poster_url;
- if ($showPoster && in_array($movie->title_type, ['TV Series', 'TV Mini Series'])) {
- $titlePrefix = str_contains($movie->title, ':')
- ? trim(explode(':', $movie->title, 2)[0])
+ $showPosterRaw = $updateData['poster_url'] ?? $movie->poster_url;
+ $showPoster = is_string($showPosterRaw) ? $showPosterRaw : null;
+ if ($showPoster !== null && in_array($movie->title_type, ['TV Series', 'TV Mini Series'], true)) {
+ $titlePrefix = str_contains((string) $movie->title, ':')
+ ? trim(explode(':', (string) $movie->title, 2)[0])
: $movie->title;
Movie::propagateShowPoster(
$this->userId,
@@ -221,15 +231,15 @@ public function handle(): void
'total' => count($this->movieIds),
'fetched' => $fetched,
'applied' => $applied,
- 'started_at' => Cache::get($cacheKey)['started_at'] ?? now()->toIso8601String(),
+ 'started_at' => $startedAt,
'updated_at' => now()->toIso8601String(),
], now()->addHours(2));
// 300ms delay to respect TMDB rate limits
- usleep(300000);
+ Sleep::usleep(300000);
- } catch (\Exception $e) {
- Log::warning("FetchMovieMetadata: Error fetching movie {$movieId}: " . $e->getMessage());
+ } catch (Exception $e) {
+ Log::warning("FetchMovieMetadata: Error fetching movie {$movieId}: ".$e->getMessage());
}
}
@@ -239,7 +249,7 @@ public function handle(): void
'total' => count($this->movieIds),
'fetched' => $fetched,
'applied' => $applied,
- 'started_at' => Cache::get($cacheKey)['started_at'] ?? now()->toIso8601String(),
+ 'started_at' => $startedAt,
'completed_at' => now()->toIso8601String(),
'updated_at' => now()->toIso8601String(),
], now()->addHours(2));
@@ -247,14 +257,19 @@ public function handle(): void
Log::info("FetchMovieMetadata: Completed for user {$this->userId}. Fetched: {$fetched}, Applied: {$applied}");
}
+ /**
+ * @return array|null
+ */
public static function getStatus(int $userId): ?array
{
- return Cache::get(self::CACHE_KEY_PREFIX . $userId);
+ $status = Cache::get(self::CACHE_KEY_PREFIX.$userId);
+
+ return is_array($status) ? $status : null;
}
public static function clearStatus(int $userId): void
{
- Cache::forget(self::CACHE_KEY_PREFIX . $userId);
+ Cache::forget(self::CACHE_KEY_PREFIX.$userId);
}
public static function isRunning(int $userId): bool
diff --git a/app/Jobs/ImportFromJson.php b/app/Jobs/ImportFromJson.php
index dc8884d..be7d583 100644
--- a/app/Jobs/ImportFromJson.php
+++ b/app/Jobs/ImportFromJson.php
@@ -4,8 +4,10 @@
namespace App\Jobs;
+use App\Models\Book;
use App\Models\User;
use App\Services\JsonImportService;
+use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
@@ -64,7 +66,7 @@ public function handle(): void
'errors' => $result['errors'],
]);
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
Log::error("ImportFromJson: Error importing books for user {$user->id}", [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
@@ -74,15 +76,20 @@ public function handle(): void
}
}
+ /**
+ * @param list $bookIds
+ */
protected function dispatchCoverFetchJobs(array $bookIds): void
{
- foreach ($bookIds as $bookId) {
- $book = \App\Models\Book::find($bookId);
+ $books = Book::whereIn('id', $bookIds)
+ ->select('id', 'isbn', 'isbn13', 'cover_url')
+ ->get();
+ foreach ($books as $book) {
// Only dispatch for books with ISBN or direct cover URL
- if ($book && (($book->isbn || $book->isbn13) || $book->cover_url)) {
+ if (($book->isbn || $book->isbn13) || $book->cover_url) {
// Add random delay to each job to spread network requests
- FetchBookCover::dispatch($bookId)
+ dispatch(new FetchBookCover($book->id))
->delay(random_int(10, 120)); // 10-120 seconds random delay
}
}
diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php
index 3ef481d..ebe009e 100644
--- a/app/Livewire/Actions/Logout.php
+++ b/app/Livewire/Actions/Logout.php
@@ -1,5 +1,7 @@
> */
+ public array $results = [];
+
+ /** @var array|null */
+ public ?array $selectedRelease = null;
+
+ public string $status = 'wishlist';
+
+ public string $ownership = 'not_owned';
+
+ public ?int $rating = null;
+
+ public string $notes = '';
+
+ public function search(): void
+ {
+ if (empty(trim($this->searchQuery))) {
+ return;
+ }
+
+ $service = app(DiscogsService::class);
+ $this->results = $service->search($this->searchQuery);
+ $this->step = 'results';
+ }
+
+ public function selectRelease(int $index): void
+ {
+ $result = $this->results[$index] ?? null;
+
+ if (! $result) {
+ return;
+ }
+
+ $service = app(DiscogsService::class);
+
+ $masterId = $result['master_id'] ?? null;
+ $releaseId = $result['id'] ?? null;
+ $type = $result['type'] ?? 'master';
+
+ if ($type === 'master' && is_numeric($masterId)) {
+ $details = $service->getMasterDetails((int) $masterId);
+ } elseif (is_numeric($releaseId)) {
+ $details = $service->getReleaseDetails((int) $releaseId);
+ } else {
+ return;
+ }
+
+ if (! $details) {
+ session()->flash('error', 'Could not fetch release details.');
+
+ return;
+ }
+
+ $this->selectedRelease = $details;
+ $this->step = 'configure';
+ }
+
+ public function save(): void
+ {
+ if (! $this->selectedRelease) {
+ return;
+ }
+
+ $discogsId = $this->selectedRelease['discogs_id'] ?? null;
+ $discogsMasterId = $this->selectedRelease['discogs_master_id'] ?? null;
+
+ if ($discogsId || $discogsMasterId) {
+ $existing = Album::where('user_id', Auth::id())
+ ->where(function ($q) use ($discogsId, $discogsMasterId): void {
+ if ($discogsId) {
+ $q->where('discogs_id', $discogsId);
+ }
+ if ($discogsMasterId) {
+ $q->orWhere('discogs_master_id', $discogsMasterId);
+ }
+ })
+ ->exists();
+
+ if ($existing) {
+ session()->flash('error', 'This album is already in your collection.');
+
+ return;
+ }
+ }
+
+ $album = Album::create([
+ 'user_id' => Auth::id(),
+ 'title' => $this->selectedRelease['title'],
+ 'artist' => $this->selectedRelease['artist'],
+ 'genre' => $this->selectedRelease['genre'] ?? [],
+ 'styles' => $this->selectedRelease['styles'] ?? [],
+ 'year' => $this->selectedRelease['year'],
+ 'format' => $this->selectedRelease['format'],
+ 'label' => $this->selectedRelease['label'],
+ 'country' => $this->selectedRelease['country'],
+ 'cover_url' => $this->selectedRelease['cover_url'],
+ 'tracklist' => $this->selectedRelease['tracklist'] ?? [],
+ 'status' => $this->status,
+ 'ownership' => $this->ownership,
+ 'rating' => $this->rating,
+ 'discogs_id' => $this->selectedRelease['discogs_id'],
+ 'discogs_master_id' => $this->selectedRelease['discogs_master_id'],
+ 'notes' => $this->notes ?: null,
+ ]);
+
+ session()->flash('message', "{$album->title} added to your collection.");
+
+ $this->redirect(route('albums.show', $album));
+ }
+
+ public function back(): void
+ {
+ $this->step = match ($this->step) {
+ 'configure' => 'results',
+ 'results' => 'search',
+ default => 'search',
+ };
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.albums.album-discogs-search', [
+ 'statuses' => CollectionStatus::cases(),
+ 'ownershipStatuses' => OwnershipStatus::cases(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Albums/AlbumForm.php b/app/Livewire/Albums/AlbumForm.php
new file mode 100644
index 0000000..61f90f7
--- /dev/null
+++ b/app/Livewire/Albums/AlbumForm.php
@@ -0,0 +1,159 @@
+exists) {
+ $this->authorize('update', $album);
+ $this->album = $album;
+ $this->fill([
+ 'title' => $album->title,
+ 'artist' => $album->artist ?? '',
+ 'genre' => is_array($album->genre) ? implode(', ', $album->genre) : '',
+ 'styles' => is_array($album->styles) ? implode(', ', $album->styles) : '',
+ 'year' => $album->year,
+ 'format' => $album->format ?? '',
+ 'label' => $album->label ?? '',
+ 'country' => $album->country ?? '',
+ 'cover_url' => $album->cover_url ?? '',
+ 'status' => $album->status->value,
+ 'ownership' => $album->ownership->value,
+ 'rating' => $album->rating,
+ 'discogs_id' => $album->discogs_id,
+ 'discogs_master_id' => $album->discogs_master_id,
+ 'notes' => $album->notes ?? '',
+ ]);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function rules(): array
+ {
+ return [
+ 'title' => ['required', 'string', 'max:255'],
+ 'artist' => ['nullable', 'string', 'max:255'],
+ 'genre' => ['nullable', 'string', 'max:500'],
+ 'styles' => ['nullable', 'string', 'max:500'],
+ 'year' => ['nullable', 'integer', 'min:1900', 'max:2100'],
+ 'format' => ['nullable', 'string', 'max:255'],
+ 'label' => ['nullable', 'string', 'max:255'],
+ 'country' => ['nullable', 'string', 'max:255'],
+ 'cover_url' => ['nullable', 'url', 'max:2048'],
+ 'status' => ['required', Rule::enum(CollectionStatus::class)],
+ 'ownership' => ['required', Rule::enum(OwnershipStatus::class)],
+ 'rating' => ['nullable', 'integer', 'min:1', 'max:5'],
+ 'discogs_id' => ['nullable', 'integer'],
+ 'discogs_master_id' => ['nullable', 'integer'],
+ 'notes' => ['nullable', 'string', 'max:10000'],
+ ];
+ }
+
+ public function save(): void
+ {
+ $validated = $this->validate();
+ $validated = is_array($validated) ? $validated : [];
+
+ $genreValue = is_string($validated['genre'] ?? null) ? $validated['genre'] : '';
+ $genreArray = $genreValue !== '' ? array_map(trim(...), explode(',', $genreValue)) : [];
+
+ $stylesValue = is_string($validated['styles'] ?? null) ? $validated['styles'] : '';
+ $stylesArray = $stylesValue !== '' ? array_map(trim(...), explode(',', $stylesValue)) : [];
+
+ $data = [
+ 'title' => $validated['title'],
+ 'artist' => $validated['artist'] ?: null,
+ 'genre' => $genreArray,
+ 'styles' => $stylesArray,
+ 'year' => $validated['year'],
+ 'format' => $validated['format'] ?: null,
+ 'label' => $validated['label'] ?: null,
+ 'country' => $validated['country'] ?: null,
+ 'cover_url' => $validated['cover_url'] ?: null,
+ 'status' => $validated['status'],
+ 'ownership' => $validated['ownership'],
+ 'rating' => $validated['rating'],
+ 'discogs_id' => $validated['discogs_id'],
+ 'discogs_master_id' => $validated['discogs_master_id'],
+ 'notes' => $validated['notes'] ?: null,
+ ];
+
+ if ($this->album) {
+ $this->album->update($data);
+ $message = 'Album updated successfully.';
+ } else {
+ $data['user_id'] = Auth::id();
+ $this->album = Album::create($data);
+ $message = 'Album created successfully.';
+ }
+
+ session()->flash('message', $message);
+
+ $this->redirect(route('albums.show', $this->album));
+ }
+
+ public function isEditing(): bool
+ {
+ return $this->album instanceof Album && $this->album->exists;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.albums.album-form', [
+ 'statuses' => CollectionStatus::cases(),
+ 'ownershipStatuses' => OwnershipStatus::cases(),
+ 'isEditing' => $this->isEditing(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Albums/AlbumIndex.php b/app/Livewire/Albums/AlbumIndex.php
new file mode 100644
index 0000000..bad30c0
--- /dev/null
+++ b/app/Livewire/Albums/AlbumIndex.php
@@ -0,0 +1,135 @@
+ */
+ public array $selected = [];
+
+ private const ALLOWED_SORT_COLUMNS = [
+ 'title', 'artist', 'year', 'rating', 'updated_at', 'created_at',
+ ];
+
+ /** @var array */
+ protected array $queryString = [
+ 'search' => ['except' => ''],
+ 'status' => ['except' => ''],
+ 'sortBy' => ['except' => 'updated_at'],
+ 'sortDirection' => ['except' => 'desc'],
+ 'viewMode' => ['except' => 'gallery'],
+ ];
+
+ public function updatingSearch(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatingStatus(): void
+ {
+ $this->resetPage();
+ }
+
+ public function deleteAlbum(Album $album): void
+ {
+ $this->authorize('delete', $album);
+
+ $album->delete();
+
+ session()->flash('message', 'Album deleted successfully.');
+ }
+
+ public function updatedSelectAll(bool $value): void
+ {
+ if ($value) {
+ $query = $this->buildQuery();
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
+ } else {
+ $this->selected = [];
+ }
+ }
+
+ public function deleteSelected(): void
+ {
+ Album::whereIn('id', $this->selected)
+ ->where('user_id', Auth::id())
+ ->delete();
+
+ $count = count($this->selected);
+ $this->selected = [];
+ $this->selectAll = false;
+
+ session()->flash('message', "{$count} album(s) deleted.");
+ }
+
+ /**
+ * @return Builder
+ */
+ private function buildQuery(): Builder
+ {
+ $query = Album::where('user_id', Auth::id());
+
+ if ($this->search !== '') {
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'artist', 'label']);
+ }
+
+ if ($this->status !== '') {
+ $query->where('status', $this->status);
+ }
+
+ return $query;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ $perPage = $this->viewMode === 'list' ? 25 : 18;
+ $sortBy = $this->safeSortBy();
+ $sortDir = $this->safeSortDirection();
+
+ $query = $this->buildQuery();
+
+ if (in_array($sortBy, ['rating', 'year'])) {
+ $query->orderByRaw("\"$sortBy\" $sortDir NULLS LAST");
+ } else {
+ $query->orderBy($sortBy, $sortDir);
+ }
+ $query->orderBy('id');
+
+ $albums = $query->paginate($perPage);
+
+ return view('livewire.albums.album-index', [
+ 'albums' => $albums,
+ 'statuses' => CollectionStatus::cases(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Albums/AlbumShow.php b/app/Livewire/Albums/AlbumShow.php
new file mode 100644
index 0000000..775a895
--- /dev/null
+++ b/app/Livewire/Albums/AlbumShow.php
@@ -0,0 +1,48 @@
+authorize('view', $album);
+ $this->album = $album;
+ }
+
+ public function updateRating(int $rating): void
+ {
+ $this->authorize('update', $this->album);
+
+ $newRating = $this->album->rating === $rating ? null : $rating;
+ $this->album->update(['rating' => $newRating]);
+ }
+
+ public function deleteAlbum(): void
+ {
+ $this->authorize('delete', $this->album);
+
+ $this->album->delete();
+
+ session()->flash('message', 'Album deleted successfully.');
+ $this->redirect(route('albums.index'));
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.albums.album-show');
+ }
+}
diff --git a/app/Livewire/Anime/AnimeForm.php b/app/Livewire/Anime/AnimeForm.php
index 9db6fdb..c5cdddd 100644
--- a/app/Livewire/Anime/AnimeForm.php
+++ b/app/Livewire/Anime/AnimeForm.php
@@ -6,9 +6,11 @@
use App\Enums\WatchingStatus;
use App\Models\Anime;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class AnimeForm extends Component
@@ -78,6 +80,9 @@ public function mount(?Anime $anime = null): void
}
}
+ /**
+ * @return array
+ */
public function rules(): array
{
return [
@@ -101,9 +106,9 @@ public function rules(): array
];
}
- protected function parseDateInput(?string $date): ?string
+ protected function parseDateInput(mixed $date): ?string
{
- if (empty($date)) {
+ if (! is_string($date) || $date === '') {
return null;
}
@@ -127,6 +132,7 @@ protected function parseDateInput(?string $date): ?string
public function save(): void
{
$validated = $this->validate();
+ $validated = is_array($validated) ? $validated : [];
$validated['date_started'] = $this->parseDateInput($validated['date_started'] ?? null);
$validated['date_finished'] = $this->parseDateInput($validated['date_finished'] ?? null);
@@ -166,6 +172,9 @@ public function save(): void
$this->redirect(route('anime.show', $this->anime));
}
+ /**
+ * @return list
+ */
public function getStatuses(): array
{
return WatchingStatus::cases();
@@ -173,14 +182,15 @@ public function getStatuses(): array
public function isEditing(): bool
{
- return $this->anime !== null && $this->anime->exists;
+ return $this->anime instanceof Anime && $this->anime->exists;
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.anime.anime-form', [
'statuses' => $this->getStatuses(),
'isEditing' => $this->isEditing(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Anime/AnimeImport.php b/app/Livewire/Anime/AnimeImport.php
index 7731427..4d5d7d2 100644
--- a/app/Livewire/Anime/AnimeImport.php
+++ b/app/Livewire/Anime/AnimeImport.php
@@ -4,11 +4,17 @@
namespace App\Livewire\Anime;
+use App\Models\User;
use App\Services\MalImportService;
+use Exception;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
+use Livewire\Attributes\Layout;
use Livewire\Component;
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
+use RuntimeException;
class AnimeImport extends Component
{
@@ -18,12 +24,14 @@ class AnimeImport extends Component
public string $malUsername = '';
- public $file;
+ public ?TemporaryUploadedFile $file = null;
public bool $skipDuplicates = true;
+ /** @var Collection>|null */
public ?Collection $preview = null;
+ /** @var array{imported: int, skipped: int, errors: list}|null */
public ?array $importResult = null;
public bool $importing = false;
@@ -52,7 +60,7 @@ public function fetchFromMal(): void
$this->totalEntries = $entries->count();
$this->preview = $entries->take(5);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->addError('malUsername', $e->getMessage());
$this->preview = null;
} finally {
@@ -70,7 +78,11 @@ public function updatedFile(): void
protected function generateXmlPreview(): void
{
- $content = file_get_contents($this->file->getRealPath());
+ $content = $this->uploadedContent();
+
+ if ($content === null) {
+ return;
+ }
try {
$service = new MalImportService;
@@ -78,7 +90,7 @@ protected function generateXmlPreview(): void
$this->totalEntries = $entries->count();
$this->preview = $entries->take(5);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->addError('file', $e->getMessage());
$this->file = null;
$this->preview = null;
@@ -95,16 +107,21 @@ public function import(): void
if ($this->importMode === 'username') {
$entries = $service->fetchFromMal(trim($this->malUsername));
} else {
- $content = file_get_contents($this->file->getRealPath());
+ $content = $this->uploadedContent();
+
+ if ($content === null) {
+ throw new RuntimeException('Could not read the uploaded file.');
+ }
+
$entries = $service->parseXml($content);
}
$this->importResult = $service->importAll(
- Auth::user(),
+ $this->currentUser(),
$entries,
$this->skipDuplicates
);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->importResult = [
'imported' => 0,
'skipped' => 0,
@@ -126,9 +143,33 @@ public function resetForm(): void
$this->resetErrorBag();
}
- public function render()
+ private function uploadedContent(): ?string
+ {
+ $path = $this->file?->getRealPath();
+
+ if (! is_string($path) || $path === '') {
+ return null;
+ }
+
+ $content = file_get_contents($path);
+
+ return $content === false ? null : $content;
+ }
+
+ private function currentUser(): User
+ {
+ $user = Auth::user();
+
+ if (! $user instanceof User) {
+ abort(403);
+ }
+
+ return $user;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
{
- return view('livewire.anime.anime-import')
- ->layout('layouts.app');
+ return view('livewire.anime.anime-import');
}
}
diff --git a/app/Livewire/Anime/AnimeIndex.php b/app/Livewire/Anime/AnimeIndex.php
index a10060d..0c01a01 100644
--- a/app/Livewire/Anime/AnimeIndex.php
+++ b/app/Livewire/Anime/AnimeIndex.php
@@ -5,15 +5,20 @@
namespace App\Livewire\Anime;
use App\Enums\WatchingStatus;
+use App\Livewire\Concerns\WithAccentInsensitiveSearch;
+use App\Livewire\Concerns\WithIndexFiltering;
use App\Models\Anime;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Str;
+use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
class AnimeIndex extends Component
{
+ use WithAccentInsensitiveSearch;
+ use WithIndexFiltering;
use WithPagination;
public string $search = '';
@@ -30,12 +35,14 @@ class AnimeIndex extends Component
public string $viewMode = 'gallery';
+ /** @var array */
public array $selected = [];
public bool $selectAll = false;
private const ALLOWED_SORT_COLUMNS = ['title', 'rating', 'year', 'episodes_total', 'date_watched', 'updated_at', 'mal_score', 'date_started', 'date_finished'];
+ /** @var array */
protected $queryString = [
'search' => ['except' => ''],
'status' => ['except' => ''],
@@ -46,30 +53,19 @@ class AnimeIndex extends Component
'viewMode' => ['except' => 'gallery'],
];
- private function normalizeForSearch(string $string): string
- {
- return Str::ascii($string);
- }
-
- private function matchesSearch(?string $value, string $normalizedSearch): bool
- {
- if ($value === null) {
- return false;
- }
-
- return str_contains(
- strtolower($this->normalizeForSearch($value)),
- strtolower($normalizedSearch)
- );
- }
-
public function updatingSearch(): void
{
$this->resetPage();
}
- public function updatingStatus(): void
+ public function updatingStatus(string $value): void
{
+ if ($value === 'watchlist' && in_array($this->sortBy, ['date_finished', 'date_started'])) {
+ $this->sortBy = 'updated_at';
+ } elseif ($value === 'watching' && $this->sortBy === 'date_finished') {
+ $this->sortBy = 'date_started';
+ }
+
$this->resetPage();
}
@@ -83,31 +79,6 @@ public function updatingMediaType(): void
$this->resetPage();
}
- public function setViewMode(string $mode): void
- {
- $this->viewMode = in_array($mode, ['gallery', 'list']) ? $mode : 'gallery';
- }
-
- public function sort(string $column): void
- {
- if ($this->sortBy === $column) {
- $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
- } else {
- $this->sortBy = $column;
- $this->sortDirection = 'asc';
- }
- }
-
- private function safeSortDirection(): string
- {
- return $this->sortDirection === 'asc' ? 'asc' : 'desc';
- }
-
- private function safeSortBy(): string
- {
- return in_array($this->sortBy, self::ALLOWED_SORT_COLUMNS, true) ? $this->sortBy : 'updated_at';
- }
-
public function deleteAnime(Anime $anime): void
{
$this->authorize('delete', $anime);
@@ -122,26 +93,21 @@ public function updatedSelectAll(bool $value): void
if ($value) {
$query = Anime::query()
->where('user_id', Auth::id())
- ->when($this->status, function ($query) {
+ ->when($this->status, function ($query): void {
$query->where('status', $this->status);
})
- ->when($this->genre, function ($query) {
- $query->where('genres', 'like', '%' . $this->genre . '%');
+ ->when($this->genre, function ($query): void {
+ $query->where('genres', 'like', '%'.$this->genre.'%');
})
- ->when($this->mediaType, function ($query) {
+ ->when($this->mediaType, function ($query): void {
$query->where('media_type', $this->mediaType);
});
if ($this->search) {
- $normalizedSearch = $this->normalizeForSearch($this->search);
- $allAnime = $query->get();
- $this->selected = $allAnime->filter(function ($anime) use ($normalizedSearch) {
- return $this->matchesSearch($anime->title, $normalizedSearch)
- || $this->matchesSearch($anime->original_title, $normalizedSearch)
- || $this->matchesSearch($anime->studios, $normalizedSearch);
- })->pluck('id')->map(fn ($id) => (string) $id)->toArray();
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'original_title', 'studios']);
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
} else {
- $this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
}
} else {
$this->selected = [];
@@ -150,10 +116,11 @@ public function updatedSelectAll(bool $value): void
public function deleteSelected(): void
{
- $count = Anime::query()
+ $deleted = Anime::query()
->where('user_id', Auth::id())
->whereIn('id', $this->selected)
->delete();
+ $count = is_int($deleted) ? $deleted : 0;
$this->selected = [];
$this->selectAll = false;
@@ -161,17 +128,16 @@ public function deleteSelected(): void
session()->flash('message', "{$count} anime deleted successfully.");
}
+ /**
+ * @return array
+ */
public function getStatuses(): array
{
return WatchingStatus::cases();
}
- public function paginationView(): string
- {
- return 'livewire.custom-pagination';
- }
-
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
$perPage = $this->viewMode === 'list' ? 25 : 18;
$sortBy = $this->safeSortBy();
@@ -179,13 +145,13 @@ public function render()
$query = Anime::query()
->where('user_id', Auth::id())
- ->when($this->status, function ($query) {
+ ->when($this->status, function ($query): void {
$query->where('status', $this->status);
})
- ->when($this->genre, function ($query) {
- $query->where('genres', 'like', '%' . $this->genre . '%');
+ ->when($this->genre, function ($query): void {
+ $query->where('genres', 'like', '%'.$this->genre.'%');
})
- ->when($this->mediaType, function ($query) {
+ ->when($this->mediaType, function ($query): void {
$query->where('media_type', $this->mediaType);
});
@@ -195,40 +161,14 @@ public function render()
$query->orderBy($sortBy, $sortDir);
}
- if ($this->search) {
- $normalizedSearch = $this->normalizeForSearch($this->search);
-
- $exactMatchIds = (clone $query)
- ->where(function ($q) {
- $q->where('title', 'like', '%' . $this->search . '%')
- ->orWhere('original_title', 'like', '%' . $this->search . '%')
- ->orWhere('studios', 'like', '%' . $this->search . '%');
- })
- ->pluck('id');
+ $query->orderBy('id');
- $allAnime = $query->get();
- $filteredIds = $allAnime->filter(function ($anime) use ($normalizedSearch) {
- return $this->matchesSearch($anime->title, $normalizedSearch)
- || $this->matchesSearch($anime->original_title, $normalizedSearch)
- || $this->matchesSearch($anime->studios, $normalizedSearch);
- })->pluck('id');
-
- $matchingIds = $exactMatchIds->merge($filteredIds)->unique();
-
- $searchQuery = Anime::query()
- ->whereIn('id', $matchingIds);
-
- if ($sortBy === 'date_watched') {
- $searchQuery->orderBy(DB::raw('COALESCE(date_watched, updated_at)'), $sortDir);
- } else {
- $searchQuery->orderBy($sortBy, $sortDir);
- }
-
- $animeList = $searchQuery->paginate($perPage);
- } else {
- $animeList = $query->paginate($perPage);
+ if ($this->search) {
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'original_title', 'studios']);
}
+ $animeList = $query->paginate($perPage);
+
$allMediaTypes = Anime::where('user_id', Auth::id())
->whereNotNull('media_type')
->distinct()
@@ -239,8 +179,8 @@ public function render()
return view('livewire.anime.anime-index', [
'animeList' => $animeList,
'statuses' => $this->getStatuses(),
- 'allGenres' => Anime::getAllGenresForUser(Auth::id()),
+ 'allGenres' => Anime::getAllGenresForUser((int) Auth::id()),
'allMediaTypes' => $allMediaTypes,
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Anime/AnimeMetadataEnrichment.php b/app/Livewire/Anime/AnimeMetadataEnrichment.php
index 2efdbf2..35577b7 100644
--- a/app/Livewire/Anime/AnimeMetadataEnrichment.php
+++ b/app/Livewire/Anime/AnimeMetadataEnrichment.php
@@ -4,16 +4,25 @@
namespace App\Livewire\Anime;
+use App\Livewire\Concerns\WithMetadataEnrichment;
+use App\Livewire\Concerns\WithSourcePriority;
use App\Models\Anime;
use App\Services\JikanService;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class AnimeMetadataEnrichment extends Component
{
+ use WithMetadataEnrichment;
+ use WithSourcePriority;
+
+ /** @var list */
public array $sourcePriority = ['current', 'jikan'];
+ /** @var array> */
public array $animeNeedingEnrichment = [];
public bool $hasScanned = false;
@@ -24,36 +33,82 @@ class AnimeMetadataEnrichment extends Component
public ?int $reviewingAnimeId = null;
+ /** @var array|null */
public ?array $reviewingAnime = null;
+ /** @var array|null */
public ?array $reviewingMetadata = null;
+ /** @var list */
public array $selectedFields = [];
+ /** @var array> */
public array $fetchedData = [];
public int $batchLimit = 50;
+ /** @var list */
protected const ENRICHABLE_FIELDS = ['description', 'poster_url', 'runtime_minutes', 'genres', 'studios', 'episodes_total', 'media_type', 'original_title'];
- public function moveSourceUp(string $source): void
+ // Anime has no background job support, so override these to avoid errors
+ /** @var array|null */
+ public ?array $jobStatus = null;
+
+ /**
+ * @return array>
+ */
+ protected function enrichmentList(): array
{
- $index = array_search($source, $this->sourcePriority);
- if ($index > 0) {
- $temp = $this->sourcePriority[$index - 1];
- $this->sourcePriority[$index - 1] = $source;
- $this->sourcePriority[$index] = $temp;
- }
+ return $this->animeNeedingEnrichment;
}
- public function moveSourceDown(string $source): void
+ /**
+ * @param array> $list
+ */
+ protected function setEnrichmentList(array $list): void
{
- $index = array_search($source, $this->sourcePriority);
- if ($index < count($this->sourcePriority) - 1) {
- $temp = $this->sourcePriority[$index + 1];
- $this->sourcePriority[$index + 1] = $source;
- $this->sourcePriority[$index] = $temp;
- }
+ $this->animeNeedingEnrichment = $list;
+ }
+
+ protected function setReviewingId(?int $id): void
+ {
+ $this->reviewingAnimeId = $id;
+ }
+
+ /**
+ * @param array|null $item
+ */
+ protected function setReviewingItem(?array $item): void
+ {
+ $this->reviewingAnime = $item;
+ }
+
+ /**
+ * @return list
+ */
+ protected function enrichableFields(): array
+ {
+ return self::ENRICHABLE_FIELDS;
+ }
+
+ /**
+ * @param array $data
+ */
+ private function intFrom(array $data, string $key): int
+ {
+ $value = $data[$key] ?? null;
+
+ return is_numeric($value) ? (int) $value : 0;
+ }
+
+ /**
+ * @param array $data
+ */
+ private function strFrom(array $data, string $key): string
+ {
+ $value = $data[$key] ?? null;
+
+ return is_string($value) ? $value : '';
}
public function scanLibrary(): void
@@ -67,7 +122,7 @@ public function scanLibrary(): void
->where('user_id', Auth::id())
->orderByRaw("metadata_fetched_at IS NULL DESC, {$randomFunction}")
->get(['id', 'title', 'original_title', 'mal_id', 'year', 'description', 'poster_url', 'runtime_minutes', 'genres', 'studios', 'episodes_total', 'media_type', 'metadata_fetched_at'])
- ->map(function ($anime) {
+ ->map(function (Anime $anime): array {
$missing = $this->getMissingFields($anime);
return [
@@ -87,15 +142,18 @@ public function scanLibrary(): void
'original_title' => $anime->original_title,
],
'missing' => $missing,
- 'has_missing' => ! empty($missing),
+ 'has_missing' => $missing !== [],
];
})
- ->toArray();
+ ->all();
$this->hasScanned = true;
$this->isScanning = false;
}
+ /**
+ * @return list
+ */
protected function getMissingFields(Anime $anime): array
{
$missing = [];
@@ -124,7 +182,7 @@ public function startBatchFetch(): void
$service = app(JikanService::class);
$toFetch = collect($this->animeNeedingEnrichment)
- ->filter(fn ($a) => $a['has_missing'])
+ ->filter(fn ($a): bool => (bool) $a['has_missing'])
->take($this->batchLimit);
$applied = 0;
@@ -132,19 +190,21 @@ public function startBatchFetch(): void
foreach ($toFetch as $animeData) {
$metadata = null;
+ $malId = $this->intFrom($animeData, 'mal_id');
+ $title = $this->strFrom($animeData, 'title');
- if (! empty($animeData['mal_id'])) {
- $metadata = $service->findByMalId($animeData['mal_id']);
+ if ($malId !== 0) {
+ $metadata = $service->findByMalId($malId);
}
- if (! $metadata && ! empty($animeData['title'])) {
- $metadata = $service->searchByTitle($animeData['title']);
+ if (! $metadata && $title !== '') {
+ $metadata = $service->searchByTitle($title);
}
$fetched++;
if ($metadata) {
- $anime = Anime::where('user_id', Auth::id())->find($animeData['id']);
+ $anime = Anime::where('user_id', Auth::id())->find($this->intFrom($animeData, 'id'));
if ($anime) {
$updates = [];
foreach (self::ENRICHABLE_FIELDS as $field) {
@@ -178,19 +238,21 @@ public function fetchSingleAnime(int $id): void
{
$animeData = collect($this->animeNeedingEnrichment)->firstWhere('id', $id);
- if (! $animeData) {
+ if (! is_array($animeData)) {
return;
}
$service = app(JikanService::class);
$metadata = null;
+ $malId = $this->intFrom($animeData, 'mal_id');
+ $title = $this->strFrom($animeData, 'title');
- if (! empty($animeData['mal_id'])) {
- $metadata = $service->findByMalId($animeData['mal_id']);
+ if ($malId !== 0) {
+ $metadata = $service->findByMalId($malId);
}
- if (! $metadata && ! empty($animeData['title'])) {
- $metadata = $service->searchByTitle($animeData['title']);
+ if (! $metadata && $title !== '') {
+ $metadata = $service->searchByTitle($title);
}
if ($metadata) {
@@ -202,49 +264,12 @@ public function fetchSingleAnime(int $id): void
public function startReview(int $id): void
{
- $animeData = collect($this->animeNeedingEnrichment)->firstWhere('id', $id);
-
- if (! $animeData) {
- return;
- }
-
- $this->reviewingAnimeId = $id;
- $this->reviewingAnime = $animeData;
- $this->reviewingMetadata = $this->fetchedData[$id] ?? null;
- $this->selectedFields = $this->getFieldsToApply($animeData, $this->reviewingMetadata);
- $this->showReviewModal = true;
- }
-
- protected function getFieldsToApply(array $animeData, ?array $metadata): array
- {
- if (! $metadata) {
- return [];
- }
-
- $fields = [];
- $currentFirst = $this->sourcePriority[0] === 'current';
-
- foreach (self::ENRICHABLE_FIELDS as $field) {
- $hasCurrentValue = ! empty($animeData['current'][$field]);
- $hasNewValue = ! empty($metadata[$field]);
-
- if (! $hasNewValue) {
- continue;
- }
-
- if (! $hasCurrentValue) {
- $fields[] = $field;
- } elseif (! $currentFirst) {
- $fields[] = $field;
- }
- }
-
- return $fields;
+ $this->openReviewFor($id);
}
public function applyMetadata(): void
{
- if (! $this->reviewingAnimeId || ! $this->reviewingMetadata || empty($this->selectedFields)) {
+ if (! $this->reviewingAnimeId || ! $this->reviewingMetadata || $this->selectedFields === []) {
$this->closeReviewModal();
return;
@@ -260,19 +285,13 @@ public function applyMetadata(): void
return;
}
- $updateData = [];
+ $updateData = $this->buildUpdateData();
- foreach ($this->selectedFields as $field) {
- if (isset($this->reviewingMetadata[$field]) && $this->reviewingMetadata[$field] !== null) {
- $updateData[$field] = $this->reviewingMetadata[$field];
- }
- }
-
- if (! empty($updateData)) {
+ if ($updateData !== []) {
$anime->update($updateData);
}
- $this->updateLocalAnimeData($this->reviewingAnimeId, $updateData);
+ $this->updateLocalItemData($this->reviewingAnimeId, $updateData);
$this->closeReviewModal();
session()->flash('message', 'Metadata applied successfully.');
@@ -283,35 +302,6 @@ public function skipAnime(): void
$this->closeReviewModal();
}
- public function closeReviewModal(): void
- {
- $this->showReviewModal = false;
- $this->reviewingAnimeId = null;
- $this->reviewingAnime = null;
- $this->reviewingMetadata = null;
- $this->selectedFields = [];
- }
-
- protected function updateLocalAnimeData(int $animeId, array $updateData): void
- {
- foreach ($this->animeNeedingEnrichment as $index => $animeData) {
- if ($animeData['id'] === $animeId) {
- foreach ($updateData as $field => $value) {
- $this->animeNeedingEnrichment[$index]['current'][$field] = $value;
-
- $missingIndex = array_search($field, $this->animeNeedingEnrichment[$index]['missing']);
- if ($missingIndex !== false) {
- unset($this->animeNeedingEnrichment[$index]['missing'][$missingIndex]);
- $this->animeNeedingEnrichment[$index]['missing'] = array_values($this->animeNeedingEnrichment[$index]['missing']);
- }
- }
-
- $this->animeNeedingEnrichment[$index]['has_missing'] = ! empty($this->animeNeedingEnrichment[$index]['missing']);
- break;
- }
- }
- }
-
public function getSourceLabel(string $source): string
{
return match ($source) {
@@ -321,19 +311,9 @@ public function getSourceLabel(string $source): string
};
}
- public function getAnimeWithMissingCount(): int
- {
- return collect($this->animeNeedingEnrichment)->where('has_missing', true)->count();
- }
-
- public function getFetchedCount(): int
- {
- return count($this->fetchedData);
- }
-
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
- return view('livewire.anime.anime-metadata-enrichment')
- ->layout('layouts.app');
+ return view('livewire.anime.anime-metadata-enrichment');
}
}
diff --git a/app/Livewire/Anime/AnimeSettings.php b/app/Livewire/Anime/AnimeSettings.php
index 87e5150..e47798e 100644
--- a/app/Livewire/Anime/AnimeSettings.php
+++ b/app/Livewire/Anime/AnimeSettings.php
@@ -5,7 +5,9 @@
namespace App\Livewire\Anime;
use App\Models\Anime;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class AnimeSettings extends Component
@@ -37,9 +39,10 @@ public function deleteAllAnime(): void
return;
}
- $count = Anime::query()
+ $deleted = Anime::query()
->where('user_id', Auth::id())
->delete();
+ $count = is_int($deleted) ? $deleted : 0;
$this->showDeleteAllModal = false;
$this->confirmationInput = '';
@@ -70,9 +73,9 @@ protected function generateConfirmationWord(): string
return implode('', $chars);
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
- return view('livewire.anime.anime-settings')
- ->layout('layouts.app');
+ return view('livewire.anime.anime-settings');
}
}
diff --git a/app/Livewire/Anime/AnimeShow.php b/app/Livewire/Anime/AnimeShow.php
index 3cb2918..30d301c 100644
--- a/app/Livewire/Anime/AnimeShow.php
+++ b/app/Livewire/Anime/AnimeShow.php
@@ -7,7 +7,9 @@
use App\Enums\WatchingStatus;
use App\Models\Anime;
use App\Services\JikanService;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class AnimeShow extends Component
@@ -20,6 +22,7 @@ class AnimeShow extends Component
public bool $showPosterForm = false;
+ /** @var array|null */
public ?array $fetchedMetadata = null;
public bool $showMetadataPreview = false;
@@ -146,7 +149,7 @@ public function applyMetadata(): void
$updates['mal_id'] = $this->fetchedMetadata['mal_id'];
}
- if (! empty($updates)) {
+ if ($updates !== []) {
$updates['metadata_fetched_at'] = now();
$this->anime->update($updates);
} else {
@@ -167,15 +170,19 @@ public function dismissMetadata(): void
$this->showMetadataPreview = false;
}
+ /**
+ * @return array
+ */
public function getStatuses(): array
{
return WatchingStatus::cases();
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.anime.anime-show', [
'statuses' => $this->getStatuses(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/BoardGames/BoardGameBggSearch.php b/app/Livewire/BoardGames/BoardGameBggSearch.php
new file mode 100644
index 0000000..cd16821
--- /dev/null
+++ b/app/Livewire/BoardGames/BoardGameBggSearch.php
@@ -0,0 +1,177 @@
+> */
+ public array $searchResults = [];
+
+ public ?int $selectedBggId = null;
+
+ // Editable fields populated from BGG
+ public string $title = '';
+
+ public string $designer = '';
+
+ public string $publisher = '';
+
+ public string $description = '';
+
+ public string $cover_url = '';
+
+ public ?int $year_published = null;
+
+ public ?int $min_players = null;
+
+ public ?int $max_players = null;
+
+ public ?int $playing_time = null;
+
+ /** @var array */
+ public array $genre = [];
+
+ public ?float $bgg_rating = null;
+
+ // User fields
+ public string $status = 'owned';
+
+ public ?int $rating = null;
+
+ public string $notes = '';
+
+ public function search(): void
+ {
+ if (trim($this->query) === '') {
+ return;
+ }
+
+ $bgg = app(BggService::class);
+ $this->searchResults = $bgg->search($this->query);
+ $this->step = 'results';
+ }
+
+ public function selectGame(int $bggId): void
+ {
+ $bgg = app(BggService::class);
+ $details = $bgg->getDetails($bggId);
+
+ if (! $details) {
+ session()->flash('error', 'Could not fetch board game details.');
+
+ return;
+ }
+
+ $this->selectedBggId = $this->intOrNull($details['bgg_id'] ?? null);
+ $this->title = $this->strOf($details['title'] ?? null);
+ $this->designer = $this->strOf($details['designer'] ?? null);
+ $this->publisher = $this->strOf($details['publisher'] ?? null);
+ $this->description = $this->strOf($details['description'] ?? null);
+ $this->cover_url = $this->strOf($details['cover_url'] ?? null);
+ $this->year_published = $this->intOrNull($details['year_published'] ?? null);
+ $this->min_players = $this->intOrNull($details['min_players'] ?? null);
+ $this->max_players = $this->intOrNull($details['max_players'] ?? null);
+ $this->playing_time = $this->intOrNull($details['playing_time'] ?? null);
+ $this->genre = is_array($details['genres'] ?? null) ? $details['genres'] : [];
+ $this->bgg_rating = $this->floatOrNull($details['bgg_rating'] ?? null);
+
+ $this->step = 'configure';
+ }
+
+ private function strOf(mixed $value): string
+ {
+ return is_string($value) ? $value : '';
+ }
+
+ private function intOrNull(mixed $value): ?int
+ {
+ return is_numeric($value) ? (int) $value : null;
+ }
+
+ private function floatOrNull(mixed $value): ?float
+ {
+ return is_numeric($value) ? (float) $value : null;
+ }
+
+ public function save(): void
+ {
+ if (! $this->selectedBggId) {
+ return;
+ }
+
+ $boardGame = BoardGame::create([
+ 'user_id' => Auth::id(),
+ 'title' => $this->title,
+ 'genre' => $this->genre === [] ? null : $this->genre,
+ 'description' => $this->description ?: null,
+ 'cover_url' => $this->cover_url ?: null,
+ 'year_published' => $this->year_published,
+ 'designer' => $this->designer ?: null,
+ 'publisher' => $this->publisher ?: null,
+ 'min_players' => $this->min_players,
+ 'max_players' => $this->max_players,
+ 'playing_time' => $this->playing_time,
+ 'status' => $this->status,
+ 'rating' => $this->rating,
+ 'bgg_rating' => $this->bgg_rating,
+ 'bgg_id' => $this->selectedBggId,
+ 'notes' => $this->notes ?: null,
+ ]);
+
+ session()->flash('message', "{$boardGame->title} added to your collection!");
+ $this->redirect(route('board-games.show', $boardGame));
+ }
+
+ public function backToResults(): void
+ {
+ $this->resetConfigureFields();
+ $this->step = 'results';
+ }
+
+ public function backToSearch(): void
+ {
+ $this->searchResults = [];
+ $this->resetConfigureFields();
+ $this->step = 'search';
+ }
+
+ private function resetConfigureFields(): void
+ {
+ $this->selectedBggId = null;
+ $this->title = '';
+ $this->designer = '';
+ $this->publisher = '';
+ $this->description = '';
+ $this->cover_url = '';
+ $this->year_published = null;
+ $this->min_players = null;
+ $this->max_players = null;
+ $this->playing_time = null;
+ $this->genre = [];
+ $this->bgg_rating = null;
+ $this->rating = null;
+ $this->notes = '';
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.board-games.board-game-bgg-search', [
+ 'statuses' => BoardGameStatus::cases(),
+ ]);
+ }
+}
diff --git a/app/Livewire/BoardGames/BoardGameForm.php b/app/Livewire/BoardGames/BoardGameForm.php
new file mode 100644
index 0000000..9a06176
--- /dev/null
+++ b/app/Livewire/BoardGames/BoardGameForm.php
@@ -0,0 +1,175 @@
+ */
+ public array $genre = [];
+
+ public string $genreInput = '';
+
+ public string $description = '';
+
+ public string $cover_url = '';
+
+ public ?int $year_published = null;
+
+ public string $designer = '';
+
+ public string $publisher = '';
+
+ public ?int $min_players = null;
+
+ public ?int $max_players = null;
+
+ public ?int $playing_time = null;
+
+ public string $status = 'owned';
+
+ public ?int $rating = null;
+
+ public ?float $bgg_rating = null;
+
+ public ?int $plays = null;
+
+ public ?int $bgg_id = null;
+
+ public string $notes = '';
+
+ public function mount(?BoardGame $boardGame = null): void
+ {
+ if ($boardGame && $boardGame->exists) {
+ $this->authorize('update', $boardGame);
+ $this->boardGame = $boardGame;
+ $this->fill([
+ 'title' => $boardGame->title,
+ 'genre' => $boardGame->genre ?? [],
+ 'description' => $boardGame->description ?? '',
+ 'cover_url' => $boardGame->cover_url ?? '',
+ 'year_published' => $boardGame->year_published,
+ 'designer' => $boardGame->designer ?? '',
+ 'publisher' => $boardGame->publisher ?? '',
+ 'min_players' => $boardGame->min_players,
+ 'max_players' => $boardGame->max_players,
+ 'playing_time' => $boardGame->playing_time,
+ 'status' => $boardGame->status->value,
+ 'rating' => $boardGame->rating,
+ 'bgg_rating' => $boardGame->bgg_rating,
+ 'plays' => $boardGame->plays,
+ 'bgg_id' => $boardGame->bgg_id,
+ 'notes' => $boardGame->notes ?? '',
+ ]);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function rules(): array
+ {
+ return [
+ 'title' => ['required', 'string', 'max:255'],
+ 'genre' => ['nullable', 'array'],
+ 'genre.*' => ['string', 'max:100'],
+ 'description' => ['nullable', 'string', 'max:10000'],
+ 'cover_url' => ['nullable', 'url', 'max:2048'],
+ 'year_published' => ['nullable', 'integer', 'min:1900', 'max:2100'],
+ 'designer' => ['nullable', 'string', 'max:255'],
+ 'publisher' => ['nullable', 'string', 'max:255'],
+ 'min_players' => ['nullable', 'integer', 'min:1', 'max:100'],
+ 'max_players' => ['nullable', 'integer', 'min:1', 'max:100'],
+ 'playing_time' => ['nullable', 'integer', 'min:0', 'max:9999'],
+ 'status' => ['required', Rule::enum(BoardGameStatus::class)],
+ 'rating' => ['nullable', 'integer', 'min:1', 'max:10'],
+ 'bgg_rating' => ['nullable', 'numeric', 'min:0', 'max:10'],
+ 'plays' => ['nullable', 'integer', 'min:0'],
+ 'bgg_id' => ['nullable', 'integer'],
+ 'notes' => ['nullable', 'string', 'max:10000'],
+ ];
+ }
+
+ public function addGenre(): void
+ {
+ $genre = trim($this->genreInput);
+ if ($genre !== '' && ! in_array($genre, $this->genre)) {
+ $this->genre[] = $genre;
+ }
+ $this->genreInput = '';
+ }
+
+ public function removeGenre(int $index): void
+ {
+ unset($this->genre[$index]);
+ $this->genre = array_values($this->genre);
+ }
+
+ public function save(): void
+ {
+ $validated = $this->validate();
+ $validated = is_array($validated) ? $validated : [];
+
+ $data = [
+ 'title' => $validated['title'],
+ 'genre' => empty($validated['genre']) ? null : $validated['genre'],
+ 'description' => $validated['description'] ?: null,
+ 'cover_url' => $validated['cover_url'] ?: null,
+ 'year_published' => $validated['year_published'],
+ 'designer' => $validated['designer'] ?: null,
+ 'publisher' => $validated['publisher'] ?: null,
+ 'min_players' => $validated['min_players'],
+ 'max_players' => $validated['max_players'],
+ 'playing_time' => $validated['playing_time'],
+ 'status' => $validated['status'],
+ 'rating' => $validated['rating'],
+ 'bgg_rating' => $validated['bgg_rating'],
+ 'plays' => $validated['plays'],
+ 'bgg_id' => $validated['bgg_id'],
+ 'notes' => $validated['notes'] ?: null,
+ ];
+
+ if ($this->boardGame) {
+ $this->boardGame->update($data);
+ $message = 'Board game updated successfully.';
+ } else {
+ $data['user_id'] = Auth::id();
+ $this->boardGame = BoardGame::create($data);
+ $message = 'Board game created successfully.';
+ }
+
+ session()->flash('message', $message);
+
+ $this->redirect(route('board-games.show', $this->boardGame));
+ }
+
+ public function isEditing(): bool
+ {
+ return $this->boardGame instanceof BoardGame && $this->boardGame->exists;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.board-games.board-game-form', [
+ 'statuses' => BoardGameStatus::cases(),
+ 'isEditing' => $this->isEditing(),
+ ]);
+ }
+}
diff --git a/app/Livewire/BoardGames/BoardGameIndex.php b/app/Livewire/BoardGames/BoardGameIndex.php
new file mode 100644
index 0000000..bec8f27
--- /dev/null
+++ b/app/Livewire/BoardGames/BoardGameIndex.php
@@ -0,0 +1,157 @@
+ */
+ public array $selected = [];
+
+ private const ALLOWED_SORT_COLUMNS = [
+ 'title', 'rating', 'bgg_rating', 'year_published', 'plays',
+ 'updated_at', 'created_at',
+ ];
+
+ /** @var array */
+ protected array $queryString = [
+ 'search' => ['except' => ''],
+ 'status' => ['except' => ''],
+ 'genre' => ['except' => ''],
+ 'sortBy' => ['except' => 'updated_at'],
+ 'sortDirection' => ['except' => 'desc'],
+ 'viewMode' => ['except' => 'gallery'],
+ ];
+
+ public function updatingSearch(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatingStatus(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatingGenre(): void
+ {
+ $this->resetPage();
+ }
+
+ public function deleteBoardGame(BoardGame $boardGame): void
+ {
+ $this->authorize('delete', $boardGame);
+
+ $boardGame->delete();
+
+ session()->flash('message', 'Board game deleted successfully.');
+ }
+
+ public function updatedSelectAll(bool $value): void
+ {
+ if ($value) {
+ $query = $this->buildQuery();
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
+ } else {
+ $this->selected = [];
+ }
+ }
+
+ public function deleteSelected(): void
+ {
+ BoardGame::whereIn('id', $this->selected)
+ ->where('user_id', Auth::id())
+ ->delete();
+
+ $count = count($this->selected);
+ $this->selected = [];
+ $this->selectAll = false;
+
+ session()->flash('message', "{$count} board game(s) deleted.");
+ }
+
+ /**
+ * @return Builder
+ */
+ private function buildQuery(): Builder
+ {
+ $query = BoardGame::where('user_id', Auth::id());
+
+ if ($this->search !== '') {
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'designer', 'publisher']);
+ }
+
+ if ($this->status !== '') {
+ $query->where('status', $this->status);
+ }
+
+ if ($this->genre !== '') {
+ $query->whereJsonContains('genre', $this->genre);
+ }
+
+ return $query;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ $perPage = $this->viewMode === 'list' ? 25 : 18;
+ $sortBy = $this->safeSortBy();
+ $sortDir = $this->safeSortDirection();
+
+ $query = $this->buildQuery();
+
+ if (in_array($sortBy, ['rating', 'bgg_rating', 'plays', 'year_published'])) {
+ $query->orderByRaw("\"$sortBy\" $sortDir NULLS LAST");
+ } else {
+ $query->orderBy($sortBy, $sortDir);
+ }
+ $query->orderBy('id');
+
+ $boardGames = $query->paginate($perPage);
+
+ $allGenres = BoardGame::where('user_id', Auth::id())
+ ->whereNotNull('genre')
+ ->pluck('genre')
+ ->flatten()
+ ->unique()
+ ->sort()
+ ->values();
+
+ return view('livewire.board-games.board-game-index', [
+ 'boardGames' => $boardGames,
+ 'statuses' => BoardGameStatus::cases(),
+ 'allGenres' => $allGenres,
+ ]);
+ }
+}
diff --git a/app/Livewire/BoardGames/BoardGameShow.php b/app/Livewire/BoardGames/BoardGameShow.php
new file mode 100644
index 0000000..827617d
--- /dev/null
+++ b/app/Livewire/BoardGames/BoardGameShow.php
@@ -0,0 +1,48 @@
+authorize('view', $boardGame);
+ $this->boardGame = $boardGame;
+ }
+
+ public function updateRating(int $rating): void
+ {
+ $this->authorize('update', $this->boardGame);
+
+ $newRating = $this->boardGame->rating === $rating ? null : $rating;
+ $this->boardGame->update(['rating' => $newRating]);
+ }
+
+ public function deleteBoardGame(): void
+ {
+ $this->authorize('delete', $this->boardGame);
+
+ $this->boardGame->delete();
+
+ session()->flash('message', 'Board game deleted successfully.');
+ $this->redirect(route('board-games.index'));
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.board-games.board-game-show');
+ }
+}
diff --git a/app/Livewire/Books/BookForm.php b/app/Livewire/Books/BookForm.php
index f2a8fe6..8fe6261 100644
--- a/app/Livewire/Books/BookForm.php
+++ b/app/Livewire/Books/BookForm.php
@@ -6,9 +6,11 @@
use App\Enums\ReadingStatus;
use App\Models\Book;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class BookForm extends Component
@@ -47,6 +49,7 @@ class BookForm extends Component
public string $notes = '';
+ /** @var array */
public array $tags = [];
public string $newTag = '';
@@ -77,6 +80,9 @@ public function mount(?Book $book = null): void
}
}
+ /**
+ * @return array
+ */
public function rules(): array
{
return [
@@ -98,9 +104,9 @@ public function rules(): array
];
}
- protected function parseDateInput(?string $date): ?string
+ protected function parseDateInput(mixed $date): ?string
{
- if (empty($date)) {
+ if (! is_string($date) || $date === '') {
return null;
}
@@ -126,6 +132,7 @@ protected function parseDateInput(?string $date): ?string
public function save(): void
{
$validated = $this->validate();
+ $validated = is_array($validated) ? $validated : [];
// Convert dates from DD/MM/YYYY to YYYY-MM-DD if needed
$validated['published_date'] = $this->parseDateInput($validated['published_date'] ?? null);
@@ -133,11 +140,10 @@ public function save(): void
$validated['date_finished'] = $this->parseDateInput($validated['date_finished'] ?? null);
// Validate date_finished >= date_started if both are present
- if ($validated['date_started'] && $validated['date_finished']) {
- if ($validated['date_finished'] < $validated['date_started']) {
- $this->addError('date_finished', 'Date read must be after or equal to date started.');
- return;
- }
+ if ($validated['date_started'] && $validated['date_finished'] && $validated['date_finished'] < $validated['date_started']) {
+ $this->addError('date_finished', 'Date read must be after or equal to date started.');
+
+ return;
}
$data = [
@@ -180,7 +186,7 @@ public function save(): void
public function addTag(): void
{
$tag = trim($this->newTag);
- if ($tag !== '' && !in_array($tag, $this->tags)) {
+ if ($tag !== '' && ! in_array($tag, $this->tags)) {
$this->tags[] = $tag;
}
$this->newTag = '';
@@ -189,7 +195,7 @@ public function addTag(): void
public function addExistingTag(string $tag): void
{
$tag = trim($tag);
- if ($tag !== '' && !in_array($tag, $this->tags)) {
+ if ($tag !== '' && ! in_array($tag, $this->tags)) {
$this->tags[] = $tag;
}
}
@@ -200,6 +206,9 @@ public function removeTag(int $index): void
$this->tags = array_values($this->tags);
}
+ /**
+ * @return list
+ */
public function getStatuses(): array
{
return ReadingStatus::cases();
@@ -207,15 +216,16 @@ public function getStatuses(): array
public function isEditing(): bool
{
- return $this->book !== null && $this->book->exists;
+ return $this->book instanceof Book && $this->book->exists;
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.books.book-form', [
'statuses' => $this->getStatuses(),
'isEditing' => $this->isEditing(),
- 'availableTags' => Book::getAllTagsForUser(Auth::id()),
- ])->layout('layouts.app');
+ 'availableTags' => Book::getAllTagsForUser((int) Auth::id()),
+ ]);
}
}
diff --git a/app/Livewire/Books/BookImport.php b/app/Livewire/Books/BookImport.php
index 6b26341..f8b0600 100644
--- a/app/Livewire/Books/BookImport.php
+++ b/app/Livewire/Books/BookImport.php
@@ -6,31 +6,43 @@
use App\Jobs\FetchBookCover;
use App\Models\Book;
+use App\Models\User;
use App\Services\GoodReadsImportService;
use App\Services\JsonImportService;
+use Exception;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
+use InvalidArgumentException;
+use Livewire\Attributes\Layout;
use Livewire\Component;
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
+use RuntimeException;
class BookImport extends Component
{
use WithFileUploads;
- public $file;
+ public ?TemporaryUploadedFile $file = null;
public string $format = 'csv'; // csv or json
public bool $skipDuplicates = true;
+ /** @var Collection>|null */
public ?Collection $preview = null;
+ /** @var array{imported: int, skipped: int, errors: list, book_ids: list}|null */
public ?array $importResult = null;
public bool $importing = false;
public int $coverJobsDispatched = 0;
+ /**
+ * @return array
+ */
protected function rules(): array
{
$mimes = $this->format === 'json' ? 'json,txt' : 'csv,txt';
@@ -55,19 +67,21 @@ public function updatedFormat(): void
protected function generatePreview(): void
{
- $content = file_get_contents($this->file->getRealPath());
+ $content = $this->uploadedContent();
+
+ if ($content === null) {
+ return;
+ }
try {
if ($this->format === 'json') {
- $service = new JsonImportService;
- $books = $service->parseJson($content);
+ $books = (new JsonImportService)->parseJson($content);
} else {
- $service = new GoodReadsImportService;
- $books = $service->parseCSV($content);
+ $books = (new GoodReadsImportService)->parseCSV($content);
}
$this->preview = $books->take(10);
- } catch (\InvalidArgumentException $e) {
+ } catch (InvalidArgumentException $e) {
$this->addError('file', $e->getMessage());
$this->file = null;
$this->preview = null;
@@ -80,31 +94,25 @@ public function import(): void
$this->importing = true;
try {
- $content = file_get_contents($this->file->getRealPath());
+ $content = $this->uploadedContent();
+
+ if ($content === null) {
+ throw new RuntimeException('Could not read the uploaded file.');
+ }
+
+ $user = $this->currentUser();
if ($this->format === 'json') {
$service = new JsonImportService;
- $books = $service->parseJson($content);
-
- $this->importResult = $service->importBooks(
- Auth::user(),
- $books,
- $this->skipDuplicates
- );
+ $this->importResult = $service->importBooks($user, $service->parseJson($content), $this->skipDuplicates);
} else {
$service = new GoodReadsImportService;
- $books = $service->parseCSV($content);
-
- $this->importResult = $service->importBooks(
- Auth::user(),
- $books,
- $this->skipDuplicates
- );
+ $this->importResult = $service->importBooks($user, $service->parseCSV($content), $this->skipDuplicates);
}
// Dispatch cover fetch jobs for imported books (runs after response)
- $this->coverJobsDispatched = $this->dispatchCoverFetchJobs($this->importResult['book_ids'] ?? []);
- } catch (\Exception $e) {
+ $this->coverJobsDispatched = $this->dispatchCoverFetchJobs($this->importResult['book_ids']);
+ } catch (Exception $e) {
$this->importResult = [
'imported' => 0,
'skipped' => 0,
@@ -118,24 +126,20 @@ public function import(): void
}
}
+ /**
+ * @param list $bookIds
+ */
protected function dispatchCoverFetchJobs(array $bookIds): int
{
$dispatched = 0;
+ $books = Book::whereIn('id', $bookIds)->get();
- foreach ($bookIds as $bookId) {
- $book = Book::find($bookId);
-
- if (! $book) {
- continue;
- }
-
- // Check if book has external cover URL or ISBN for lookup
+ foreach ($books as $book) {
$hasExternalUrl = $book->cover_url && filter_var($book->cover_url, FILTER_VALIDATE_URL);
$hasIsbn = $book->isbn || $book->isbn13;
if ($hasExternalUrl || $hasIsbn) {
- // Dispatch after response - runs in background after user sees result
- FetchBookCover::dispatchAfterResponse($bookId, $hasExternalUrl ? $book->cover_url : null);
+ FetchBookCover::dispatchAfterResponse($book->id, $hasExternalUrl ? $book->cover_url : null);
$dispatched++;
}
}
@@ -152,8 +156,33 @@ public function resetForm(): void
$this->format = 'csv';
}
- public function render()
+ private function uploadedContent(): ?string
+ {
+ $path = $this->file?->getRealPath();
+
+ if (! is_string($path) || $path === '') {
+ return null;
+ }
+
+ $content = file_get_contents($path);
+
+ return $content === false ? null : $content;
+ }
+
+ private function currentUser(): User
+ {
+ $user = Auth::user();
+
+ if (! $user instanceof User) {
+ abort(403);
+ }
+
+ return $user;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
{
- return view('livewire.books.book-import')->layout('layouts.app');
+ return view('livewire.books.book-import');
}
}
diff --git a/app/Livewire/Books/BookIndex.php b/app/Livewire/Books/BookIndex.php
index 068164c..e23c918 100644
--- a/app/Livewire/Books/BookIndex.php
+++ b/app/Livewire/Books/BookIndex.php
@@ -5,40 +5,22 @@
namespace App\Livewire\Books;
use App\Enums\ReadingStatus;
+use App\Livewire\Concerns\WithAccentInsensitiveSearch;
+use App\Livewire\Concerns\WithIndexFiltering;
use App\Models\Book;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Str;
+use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
class BookIndex extends Component
{
+ use WithAccentInsensitiveSearch;
+ use WithIndexFiltering;
use WithPagination;
- /**
- * Normalize a string by removing diacritics/accents for comparison.
- */
- private function normalizeForSearch(string $string): string
- {
- return Str::ascii($string);
- }
-
- /**
- * Check if a value matches the search term (accent-insensitive).
- */
- private function matchesSearch(?string $value, string $normalizedSearch): bool
- {
- if ($value === null) {
- return false;
- }
-
- return str_contains(
- strtolower($this->normalizeForSearch($value)),
- strtolower($normalizedSearch)
- );
- }
-
public string $search = '';
public string $status = '';
@@ -52,12 +34,14 @@ private function matchesSearch(?string $value, string $normalizedSearch): bool
public string $viewMode = 'gallery'; // gallery or list
// Bulk delete
+ /** @var array */
public array $selected = [];
public bool $selectAll = false;
- private const ALLOWED_SORT_COLUMNS = ['title', 'author', 'rating', 'page_count', 'date_finished', 'updated_at', 'date_started', 'date_recorded'];
+ private const ALLOWED_SORT_COLUMNS = ['title', 'author', 'rating', 'page_count', 'date_finished', 'date_added', 'updated_at', 'date_started'];
+ /** @var array */
protected $queryString = [
'search' => ['except' => ''],
'status' => ['except' => ''],
@@ -72,8 +56,15 @@ public function updatingSearch(): void
$this->resetPage();
}
- public function updatingStatus(): void
+ public function updatingStatus(string $value): void
{
+ // Reset sort if it no longer applies to the new status
+ if ($value === 'want_to_read' && in_array($this->sortBy, ['date_finished', 'date_started'])) {
+ $this->sortBy = 'date_added';
+ } elseif ($value === 'reading' && $this->sortBy === 'date_finished') {
+ $this->sortBy = 'date_started';
+ }
+
$this->resetPage();
}
@@ -88,31 +79,6 @@ public function clearTag(): void
$this->resetPage();
}
- public function setViewMode(string $mode): void
- {
- $this->viewMode = in_array($mode, ['gallery', 'list']) ? $mode : 'gallery';
- }
-
- public function sort(string $column): void
- {
- if ($this->sortBy === $column) {
- $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
- } else {
- $this->sortBy = $column;
- $this->sortDirection = 'asc';
- }
- }
-
- private function safeSortDirection(): string
- {
- return $this->sortDirection === 'asc' ? 'asc' : 'desc';
- }
-
- private function safeSortBy(): string
- {
- return in_array($this->sortBy, self::ALLOWED_SORT_COLUMNS, true) ? $this->sortBy : 'updated_at';
- }
-
public function updateStatus(Book $book, string $status): void
{
$this->authorize('update', $book);
@@ -148,15 +114,15 @@ public function updatedSelectAll(bool $value): void
if ($value) {
$query = Book::query()
->where('user_id', Auth::id())
- ->when($this->status, function ($query) {
+ ->when($this->status, function ($query): void {
$query->where('status', $this->status);
})
- ->when($this->tag, function ($query) {
+ ->when($this->tag, function ($query): void {
if ($this->tag === '__untagged__') {
- $query->where(function ($q) {
+ $query->where(function ($q): void {
$q->whereNull('shelves')
- ->orWhere('shelves', '')
- ->orWhereRaw("TRIM(shelves) IN ('read', 'to-read', 'currently-reading', 'want-to-read')");
+ ->orWhere('shelves', '')
+ ->orWhereRaw("TRIM(shelves) IN ('read', 'to-read', 'currently-reading', 'want-to-read')");
});
} else {
$query->where('shelves', 'like', '%'.$this->tag.'%');
@@ -164,14 +130,10 @@ public function updatedSelectAll(bool $value): void
});
if ($this->search) {
- $normalizedSearch = $this->normalizeForSearch($this->search);
- $allBooks = $query->get();
- $this->selected = $allBooks->filter(function ($book) use ($normalizedSearch) {
- return $this->matchesSearch($book->title, $normalizedSearch)
- || $this->matchesSearch($book->author, $normalizedSearch);
- })->pluck('id')->map(fn ($id) => (string) $id)->toArray();
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'author']);
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
} else {
- $this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
}
} else {
$this->selected = [];
@@ -180,10 +142,11 @@ public function updatedSelectAll(bool $value): void
public function deleteSelected(): void
{
- $count = Book::query()
+ $deleted = Book::query()
->where('user_id', Auth::id())
->whereIn('id', $this->selected)
->delete();
+ $count = is_int($deleted) ? $deleted : 0;
$this->selected = [];
$this->selectAll = false;
@@ -191,17 +154,16 @@ public function deleteSelected(): void
session()->flash('message', "{$count} book(s) deleted successfully.");
}
+ /**
+ * @return array
+ */
public function getStatuses(): array
{
return ReadingStatus::cases();
}
- public function paginationView(): string
- {
- return 'livewire.custom-pagination';
- }
-
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
$perPage = $this->viewMode === 'list' ? 25 : 18;
$sortBy = $this->safeSortBy();
@@ -210,16 +172,16 @@ public function render()
$query = Book::query()
->where('user_id', Auth::id())
->with('bookShelves')
- ->when($this->status, function ($query) {
+ ->when($this->status, function ($query): void {
$query->where('status', $this->status);
})
- ->when($this->tag, function ($query) {
+ ->when($this->tag, function ($query): void {
if ($this->tag === '__untagged__') {
// Filter for books with no tags
- $query->where(function ($q) {
+ $query->where(function ($q): void {
$q->whereNull('shelves')
- ->orWhere('shelves', '')
- ->orWhereRaw("TRIM(shelves) IN ('read', 'to-read', 'currently-reading', 'want-to-read')");
+ ->orWhere('shelves', '')
+ ->orWhereRaw("TRIM(shelves) IN ('read', 'to-read', 'currently-reading', 'want-to-read')");
});
} else {
// Filter by tag in the shelves field (comma-separated)
@@ -241,62 +203,24 @@ public function render()
->orderByRaw('CASE WHEN page_count IS NULL THEN title END DESC'); // NULLs sorted by title Z-A
}
} elseif ($sortBy === 'date_finished') {
- $query->orderBy(\Illuminate\Support\Facades\DB::raw('COALESCE(date_finished, updated_at)'), $sortDir);
+ $query->orderBy(DB::raw('COALESCE(date_finished, updated_at)'), $sortDir);
} else {
$query->orderBy($sortBy, $sortDir);
}
- // For search, use accent-insensitive PHP filtering
- if ($this->search) {
- $normalizedSearch = $this->normalizeForSearch($this->search);
+ // Tiebreaker for stable pagination (prevents duplicates across pages)
+ $query->orderBy('id');
- // First try exact match in SQL for performance
- $exactMatchIds = (clone $query)
- ->where(function ($q) {
- $q->where('title', 'like', '%'.$this->search.'%')
- ->orWhere('author', 'like', '%'.$this->search.'%');
- })
- ->pluck('id');
-
- // Then get all books and filter with accent-insensitive comparison
- $allBooks = $query->get();
- $filteredIds = $allBooks->filter(function ($book) use ($normalizedSearch) {
- return $this->matchesSearch($book->title, $normalizedSearch)
- || $this->matchesSearch($book->author, $normalizedSearch);
- })->pluck('id');
-
- // Combine both result sets
- $matchingIds = $exactMatchIds->merge($filteredIds)->unique();
-
- $searchQuery = Book::query()
- ->whereIn('id', $matchingIds)
- ->with('bookShelves');
-
- if ($sortBy === 'page_count') {
- if ($sortDir === 'asc') {
- $searchQuery->orderByRaw('page_count IS NOT NULL')
- ->orderByRaw('CASE WHEN page_count IS NULL THEN title END ASC')
- ->orderBy('page_count', 'asc');
- } else {
- $searchQuery->orderByRaw('page_count IS NULL')
- ->orderBy('page_count', 'desc')
- ->orderByRaw('CASE WHEN page_count IS NULL THEN title END DESC');
- }
- } elseif ($sortBy === 'date_finished') {
- $searchQuery->orderBy(\Illuminate\Support\Facades\DB::raw('COALESCE(date_finished, updated_at)'), $sortDir);
- } else {
- $searchQuery->orderBy($sortBy, $sortDir);
- }
-
- $books = $searchQuery->paginate($perPage);
- } else {
- $books = $query->paginate($perPage);
+ if ($this->search) {
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'author']);
}
+ $books = $query->paginate($perPage);
+
return view('livewire.books.book-index', [
'books' => $books,
'statuses' => $this->getStatuses(),
- 'allTags' => Book::getAllTagsForUser(Auth::id()),
- ])->layout('layouts.app');
+ 'allTags' => Book::getAllTagsForUser((int) Auth::id()),
+ ]);
}
}
diff --git a/app/Livewire/Books/BookOpenLibrarySearch.php b/app/Livewire/Books/BookOpenLibrarySearch.php
new file mode 100644
index 0000000..45a8a85
--- /dev/null
+++ b/app/Livewire/Books/BookOpenLibrarySearch.php
@@ -0,0 +1,209 @@
+> */
+ public array $searchResults = [];
+
+ public int $totalPages = 0;
+
+ public int $currentPage = 1;
+
+ // Selected book configuration
+ public string $title = '';
+
+ public string $author = '';
+
+ public ?string $isbn = null;
+
+ public ?int $page_count = null;
+
+ public string $description = '';
+
+ public string $cover_url = '';
+
+ public ?string $publisher = null;
+
+ public ?int $published_year = null;
+
+ public string $status = 'want_to_read';
+
+ public ?int $rating = null;
+
+ // Duplicate detection
+ /** @var array */
+ public array $existingIsbns = [];
+
+ public function mount(): void
+ {
+ $userId = Auth::id();
+
+ $this->existingIsbns = Book::where('user_id', $userId)
+ ->whereNotNull('isbn')
+ ->pluck('isbn')
+ ->merge(
+ Book::where('user_id', $userId)
+ ->whereNotNull('isbn13')
+ ->pluck('isbn13')
+ )
+ ->all();
+ }
+
+ public function search(): void
+ {
+ $query = trim($this->query);
+ if ($query === '') {
+ return;
+ }
+
+ $result = $this->searchWithSource($query, 1);
+
+ $this->searchResults = $result['results'];
+ $this->totalPages = min($result['total_pages'], 50);
+ $this->currentPage = 1;
+ $this->step = 'results';
+ }
+
+ public function loadPage(int $page): void
+ {
+ $result = $this->searchWithSource(trim($this->query), $page);
+
+ $this->searchResults = $result['results'];
+ $this->currentPage = $page;
+ }
+
+ /**
+ * @return array{results: list>, total: int, total_pages: int}
+ */
+ protected function searchWithSource(string $query, int $page): array
+ {
+ if ($this->searchSource === 'google_books') {
+ return app(GoogleBooksService::class)->search($query, $page);
+ }
+
+ return app(OpenLibraryService::class)->search($query, $page);
+ }
+
+ public function selectResult(int $index): void
+ {
+ $result = $this->searchResults[$index] ?? null;
+ if (! $result) {
+ return;
+ }
+
+ $this->title = $this->strOf($result['title'] ?? null);
+ $this->author = $this->strOf($result['author'] ?? null);
+ $this->isbn = $this->strOrNull($result['isbn'] ?? null);
+ $this->page_count = $this->intOrNull($result['page_count'] ?? null);
+ $this->cover_url = $this->strOf($result['cover_url_large'] ?? $result['cover_url'] ?? null);
+ $this->publisher = $this->strOrNull($result['publisher'] ?? null);
+ $this->published_year = $this->intOrNull($result['first_publish_year'] ?? null);
+ $this->description = $this->strOf($result['description'] ?? null);
+ $this->status = 'want_to_read';
+ $this->rating = null;
+
+ // If no description from search results, try ISBN lookup (OpenLibrary)
+ if (empty($this->description) && $this->isbn) {
+ $service = app(OpenLibraryService::class);
+ $details = $service->fetchByIsbn($this->isbn);
+ if ($details) {
+ $this->description = $this->strOf($details['description'] ?? null);
+ $this->page_count ??= $this->intOrNull($details['page_count'] ?? null);
+ $this->publisher ??= $this->strOrNull($details['publisher'] ?? null);
+ }
+ }
+
+ $this->step = 'configure';
+ }
+
+ private function strOf(mixed $value): string
+ {
+ return is_string($value) ? $value : '';
+ }
+
+ private function strOrNull(mixed $value): ?string
+ {
+ return is_string($value) && $value !== '' ? $value : null;
+ }
+
+ private function intOrNull(mixed $value): ?int
+ {
+ return is_numeric($value) ? (int) $value : null;
+ }
+
+ public function addBook(): void
+ {
+ $publishedDate = null;
+ if ($this->published_year) {
+ $publishedDate = $this->published_year.'-01-01';
+ }
+
+ $book = Book::create([
+ 'user_id' => Auth::id(),
+ 'title' => $this->title,
+ 'author' => $this->author ?: null,
+ 'isbn' => $this->isbn ?: null,
+ 'cover_url' => $this->cover_url ?: null,
+ 'description' => $this->description ?: null,
+ 'page_count' => $this->page_count,
+ 'publisher' => $this->publisher ?: null,
+ 'published_date' => $publishedDate,
+ 'status' => $this->status,
+ 'rating' => $this->rating,
+ 'date_added' => now(),
+ ]);
+
+ session()->flash('message', "Added \"{$this->title}\" to your library.");
+ $this->redirect(route('books.show', $book));
+ }
+
+ public function backToSearch(): void
+ {
+ $this->step = 'search';
+ $this->searchResults = [];
+ $this->query = '';
+ }
+
+ public function backToResults(): void
+ {
+ $this->step = 'results';
+ }
+
+ /**
+ * @param array $result
+ */
+ public function isResultDuplicate(array $result): bool
+ {
+ $isbn = $result['isbn'] ?? null;
+
+ return $isbn && in_array($isbn, $this->existingIsbns);
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.books.book-openlibrary-search', [
+ 'statuses' => ReadingStatus::cases(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Books/BookSettings.php b/app/Livewire/Books/BookSettings.php
index 9145018..4a910d2 100644
--- a/app/Livewire/Books/BookSettings.php
+++ b/app/Livewire/Books/BookSettings.php
@@ -6,8 +6,10 @@
use App\Jobs\FetchBookCover;
use App\Models\Book;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class BookSettings extends Component
@@ -40,9 +42,10 @@ public function deleteAllBooks(): void
return;
}
- $count = Book::query()
+ $deleted = Book::query()
->where('user_id', Auth::id())
->delete();
+ $count = is_int($deleted) ? $deleted : 0;
$this->showDeleteAllModal = false;
$this->confirmationInput = '';
@@ -75,38 +78,43 @@ protected function generateConfirmationWord(): string
public function recacheCovers(): void
{
- // Get all books with ISBNs
- $books = Book::query()
+ $query = Book::query()
->where('user_id', Auth::id())
- ->where(function ($query) {
- $query->whereNotNull('isbn')
+ ->where(function ($q): void {
+ $q->whereNotNull('isbn')
->orWhereNotNull('isbn13');
- })
- ->get();
+ });
- $count = 0;
+ // Get IDs and cover URLs for cleanup, avoid loading full models
+ $books = $query->clone()->select('id', 'cover_url')->get();
+ // Delete local cover files
+ $filesToDelete = $books
+ ->filter(fn ($book): bool => $book->cover_url && str_starts_with((string) $book->cover_url, '/storage/covers/'))
+ ->map(fn ($book): string => str_replace('/storage/', '', $book->cover_url ?? ''))
+ ->values()
+ ->all();
+
+ if (! empty($filesToDelete)) {
+ Storage::disk('public')->delete($filesToDelete);
+ }
+
+ // Batch update all cover URLs to null
+ $query->update(['cover_url' => null]);
+
+ // Batch dispatch cover fetch jobs
foreach ($books as $book) {
- // Delete existing local cover file if it exists
- if ($book->cover_url && str_starts_with($book->cover_url, '/storage/covers/')) {
- $filename = str_replace('/storage/', '', $book->cover_url);
- Storage::disk('public')->delete($filename);
- }
-
- // Clear cover_url
- $book->update(['cover_url' => null]);
-
- // Dispatch job to fetch fresh cover
- FetchBookCover::dispatch($book->id);
- $count++;
+ dispatch(new FetchBookCover($book->id));
}
+ $count = $books->count();
+
session()->flash('message', "Re-caching covers for {$count} book(s). This runs in the background.");
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
- return view('livewire.books.book-settings')
- ->layout('layouts.app');
+ return view('livewire.books.book-settings');
}
}
diff --git a/app/Livewire/Books/BookShow.php b/app/Livewire/Books/BookShow.php
index 250221c..bf1e9d7 100644
--- a/app/Livewire/Books/BookShow.php
+++ b/app/Livewire/Books/BookShow.php
@@ -6,8 +6,10 @@
use App\Enums\ReadingStatus;
use App\Models\Book;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class BookShow extends Component
@@ -56,7 +58,8 @@ public function addToQueue(): void
$maxPosition = Book::where('user_id', Auth::id())
->whereNotNull('queue_position')
- ->max('queue_position') ?? 0;
+ ->max('queue_position');
+ $maxPosition = is_numeric($maxPosition) ? (int) $maxPosition : 0;
$this->book->update(['queue_position' => $maxPosition + 1]);
$this->book->refresh();
@@ -97,15 +100,19 @@ public function deleteBook(): void
$this->redirect(route('books.index'));
}
+ /**
+ * @return array
+ */
public function getStatuses(): array
{
return ReadingStatus::cases();
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.books.book-show', [
'statuses' => $this->getStatuses(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Books/JsonImport.php b/app/Livewire/Books/JsonImport.php
index 4b6de3a..aff5ae8 100644
--- a/app/Livewire/Books/JsonImport.php
+++ b/app/Livewire/Books/JsonImport.php
@@ -5,22 +5,30 @@
namespace App\Livewire\Books;
use App\Jobs\ImportFromJson;
+use App\Models\User;
use App\Services\JsonImportService;
+use Exception;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
+use Livewire\Attributes\Layout;
use Livewire\Component;
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
+use RuntimeException;
class JsonImport extends Component
{
use WithFileUploads;
- public $file;
+ public ?TemporaryUploadedFile $file = null;
public bool $skipDuplicates = true;
+ /** @var Collection>|null */
public ?Collection $preview = null;
+ /** @var array|null */
public ?array $importResult = null;
public bool $importing = false;
@@ -29,6 +37,7 @@ class JsonImport extends Component
public int $jobId = 0;
+ /** @var array */
protected $rules = [
'file' => ['required', 'file', 'mimes:json', 'max:10240'],
];
@@ -42,7 +51,15 @@ public function updatedFile(): void
protected function generatePreview(): void
{
try {
- $content = file_get_contents($this->file->getRealPath());
+ $content = $this->uploadedContent();
+
+ if ($content === null) {
+ $this->importStatus = 'Could not read the uploaded file.';
+ $this->preview = null;
+
+ return;
+ }
+
$service = new JsonImportService;
$books = $service->parseJson($content);
@@ -55,7 +72,7 @@ protected function generatePreview(): void
$this->preview = $books->take(10);
$this->importStatus = 'Found '.$books->count().' books in JSON file';
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->importStatus = 'Error parsing JSON: '.$e->getMessage();
$this->preview = null;
}
@@ -69,13 +86,13 @@ public function import(): void
$this->importStatus = 'Queuing import job...';
try {
- $content = file_get_contents($this->file->getRealPath());
+ $content = $this->uploadedContent();
- ImportFromJson::dispatch(
- Auth::id(),
- $content,
- $this->skipDuplicates
- );
+ if ($content === null) {
+ throw new RuntimeException('Could not read the uploaded file.');
+ }
+
+ dispatch(new ImportFromJson($this->currentUser()->id, $content, $this->skipDuplicates));
$this->importStatus = 'Import job queued! Books will be imported in the background.';
$this->importing = false;
@@ -83,7 +100,7 @@ public function import(): void
$this->file = null;
$this->dispatch('import-queued');
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->importStatus = 'Error: '.$e->getMessage();
$this->importing = false;
}
@@ -98,8 +115,33 @@ public function resetForm(): void
$this->jobId = 0;
}
- public function render()
+ private function uploadedContent(): ?string
+ {
+ $path = $this->file?->getRealPath();
+
+ if (! is_string($path) || $path === '') {
+ return null;
+ }
+
+ $content = file_get_contents($path);
+
+ return $content === false ? null : $content;
+ }
+
+ private function currentUser(): User
+ {
+ $user = Auth::user();
+
+ if (! $user instanceof User) {
+ abort(403);
+ }
+
+ return $user;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
{
- return view('livewire.books.json-import')->layout('layouts.app');
+ return view('livewire.books.json-import');
}
}
diff --git a/app/Livewire/Books/MetadataEnrichment.php b/app/Livewire/Books/MetadataEnrichment.php
index 70f5bb5..70478e7 100644
--- a/app/Livewire/Books/MetadataEnrichment.php
+++ b/app/Livewire/Books/MetadataEnrichment.php
@@ -5,18 +5,27 @@
namespace App\Livewire\Books;
use App\Jobs\FetchBookMetadata;
+use App\Livewire\Concerns\WithMetadataEnrichment;
+use App\Livewire\Concerns\WithSourcePriority;
use App\Models\Book;
use App\Services\OpenLibraryService;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class MetadataEnrichment extends Component
{
+ use WithMetadataEnrichment;
+ use WithSourcePriority;
+
// Source priority: sources listed in order of preference
+ /** @var list */
public array $sourcePriority = ['current', 'openlibrary'];
// Scanning state
+ /** @var array> */
public array $booksNeedingEnrichment = [];
public bool $hasScanned = false;
@@ -24,6 +33,7 @@ class MetadataEnrichment extends Component
public bool $isScanning = false;
// Background job status
+ /** @var array|null */
public ?array $jobStatus = null;
// Review modal (single book)
@@ -31,52 +41,93 @@ class MetadataEnrichment extends Component
public ?int $reviewingBookId = null;
+ /** @var array|null */
public ?array $reviewingBook = null;
+ /** @var array|null */
public ?array $reviewingMetadata = null;
+ /** @var list */
public array $selectedFields = [];
// Fetched data for single-book fetch (review modal)
+ /** @var array> */
public array $fetchedData = [];
public int $batchLimit = 100;
- public function mount(): void
+ /**
+ * @return array>
+ */
+ protected function enrichmentList(): array
{
- // Check if there's an existing job running
- $this->refreshJobStatus();
+ return $this->booksNeedingEnrichment;
}
- public function refreshJobStatus(): void
+ /**
+ * @param array> $list
+ */
+ protected function setEnrichmentList(array $list): void
{
- $this->jobStatus = FetchBookMetadata::getStatus(Auth::id());
+ $this->booksNeedingEnrichment = $list;
}
- public function clearJobStatus(): void
+ protected function setReviewingId(?int $id): void
{
- FetchBookMetadata::clearStatus(Auth::id());
- $this->jobStatus = null;
+ $this->reviewingBookId = $id;
}
- public function moveSourceUp(string $source): void
+ /**
+ * @param array|null $item
+ */
+ protected function setReviewingItem(?array $item): void
{
- $index = array_search($source, $this->sourcePriority);
- if ($index > 0) {
- $temp = $this->sourcePriority[$index - 1];
- $this->sourcePriority[$index - 1] = $source;
- $this->sourcePriority[$index] = $temp;
- }
+ $this->reviewingBook = $item;
}
- public function moveSourceDown(string $source): void
+ /**
+ * @return list
+ */
+ protected function enrichableFields(): array
{
- $index = array_search($source, $this->sourcePriority);
- if ($index < count($this->sourcePriority) - 1) {
- $temp = $this->sourcePriority[$index + 1];
- $this->sourcePriority[$index + 1] = $source;
- $this->sourcePriority[$index] = $temp;
- }
+ return ['description', 'publisher', 'page_count', 'published_date'];
+ }
+
+ /**
+ * @param array|null $data
+ */
+ private function intFrom(?array $data, string $key): int
+ {
+ $value = $data[$key] ?? null;
+
+ return is_numeric($value) ? (int) $value : 0;
+ }
+
+ /**
+ * @param array $data
+ */
+ private function strFrom(array $data, string $key): string
+ {
+ $value = $data[$key] ?? null;
+
+ return is_string($value) ? $value : '';
+ }
+
+ public function mount(): void
+ {
+ // Check if there's an existing job running
+ $this->refreshJobStatus();
+ }
+
+ public function refreshJobStatus(): void
+ {
+ $this->jobStatus = FetchBookMetadata::getStatus((int) Auth::id());
+ }
+
+ public function clearJobStatus(): void
+ {
+ FetchBookMetadata::clearStatus((int) Auth::id());
+ $this->jobStatus = null;
}
public function scanLibrary(): void
@@ -88,12 +139,12 @@ public function scanLibrary(): void
// Find all books with ISBN that have missing metadata
$this->booksNeedingEnrichment = Book::query()
->where('user_id', Auth::id())
- ->where(function ($query) {
+ ->where(function ($query): void {
$query->whereNotNull('isbn')
->orWhereNotNull('isbn13');
})
->get(['id', 'title', 'author', 'isbn', 'isbn13', 'description', 'publisher', 'page_count', 'published_date'])
- ->map(function ($book) {
+ ->map(function (Book $book): array {
$missing = $this->getMissingFields($book);
return [
@@ -108,15 +159,18 @@ public function scanLibrary(): void
'published_date' => $book->published_date?->format('Y-m-d'),
],
'missing' => $missing,
- 'has_missing' => ! empty($missing),
+ 'has_missing' => $missing !== [],
];
})
- ->toArray();
+ ->all();
$this->hasScanned = true;
$this->isScanning = false;
}
+ /**
+ * @return list
+ */
protected function getMissingFields(Book $book): array
{
$missing = [];
@@ -139,7 +193,7 @@ protected function getMissingFields(Book $book): array
public function startBackgroundFetch(): void
{
- if (FetchBookMetadata::isRunning(Auth::id())) {
+ if (FetchBookMetadata::isRunning((int) Auth::id())) {
session()->flash('error', 'A metadata fetch is already running.');
return;
@@ -147,11 +201,11 @@ public function startBackgroundFetch(): void
// Get books that need enrichment (have missing fields)
$booksToFetch = collect($this->booksNeedingEnrichment)
- ->filter(fn ($book) => $book['has_missing'])
- ->filter(fn ($book) => ! empty($book['isbn']))
+ ->filter(fn ($book): bool => (bool) $book['has_missing'])
+ ->filter(fn ($book): bool => ! empty($book['isbn']))
->take($this->batchLimit)
->pluck('id')
- ->toArray();
+ ->all();
if (empty($booksToFetch)) {
session()->flash('message', 'No books need metadata fetching.');
@@ -169,14 +223,14 @@ public function startBackgroundFetch(): void
'started_at' => now()->toIso8601String(),
'updated_at' => now()->toIso8601String(),
];
- Cache::put('metadata_fetch_' . Auth::id(), $initialStatus, now()->addHours(2));
+ Cache::put('metadata_fetch_'.Auth::id(), $initialStatus, now()->addHours(2));
$this->jobStatus = $initialStatus;
// Execute the job synchronously for immediate feedback
$job = new FetchBookMetadata(
- Auth::id(),
- $booksToFetch,
+ (int) Auth::id(),
+ array_values(array_map(fn ($id): int => is_numeric($id) ? (int) $id : 0, $booksToFetch)),
$this->sourcePriority
);
$job->handle();
@@ -185,8 +239,8 @@ public function startBackgroundFetch(): void
$this->refreshJobStatus();
// Show completion message
- $fetched = $this->jobStatus['fetched'] ?? 0;
- $applied = $this->jobStatus['applied'] ?? 0;
+ $fetched = $this->intFrom($this->jobStatus, 'fetched');
+ $applied = $this->intFrom($this->jobStatus, 'applied');
session()->flash('message', "Metadata fetch completed! Updated {$applied} of {$fetched} books.");
}
@@ -194,12 +248,18 @@ public function fetchSingleBook(int $id): void
{
$bookData = collect($this->booksNeedingEnrichment)->firstWhere('id', $id);
- if (! $bookData || empty($bookData['isbn'])) {
+ if (! is_array($bookData)) {
+ return;
+ }
+
+ $isbn = $this->strFrom($bookData, 'isbn');
+
+ if ($isbn === '') {
return;
}
$service = app(OpenLibraryService::class);
- $metadata = $service->fetchByIsbn($bookData['isbn']);
+ $metadata = $service->fetchByIsbn($isbn);
if ($metadata) {
$this->fetchedData[$id] = $metadata;
@@ -210,52 +270,12 @@ public function fetchSingleBook(int $id): void
public function startReview(int $id): void
{
- $bookData = collect($this->booksNeedingEnrichment)->firstWhere('id', $id);
-
- if (! $bookData) {
- return;
- }
-
- $this->reviewingBookId = $id;
- $this->reviewingBook = $bookData;
- $this->reviewingMetadata = $this->fetchedData[$id] ?? null;
-
- // Pre-select fields based on priority
- $this->selectedFields = $this->getFieldsToApply($bookData, $this->reviewingMetadata);
-
- $this->showReviewModal = true;
- }
-
- protected function getFieldsToApply(array $bookData, ?array $metadata): array
- {
- if (! $metadata) {
- return [];
- }
-
- $fields = [];
- $currentFirst = $this->sourcePriority[0] === 'current';
-
- foreach (['description', 'publisher', 'page_count', 'published_date'] as $field) {
- $hasCurrentValue = ! empty($bookData['current'][$field]);
- $hasNewValue = ! empty($metadata[$field]);
-
- if (! $hasNewValue) {
- continue;
- }
-
- if (! $hasCurrentValue) {
- $fields[] = $field;
- } elseif (! $currentFirst) {
- $fields[] = $field;
- }
- }
-
- return $fields;
+ $this->openReviewFor($id);
}
public function applyMetadata(): void
{
- if (! $this->reviewingBookId || ! $this->reviewingMetadata || empty($this->selectedFields)) {
+ if (! $this->reviewingBookId || ! $this->reviewingMetadata || $this->selectedFields === []) {
$this->closeReviewModal();
return;
@@ -271,19 +291,13 @@ public function applyMetadata(): void
return;
}
- $updateData = [];
-
- foreach ($this->selectedFields as $field) {
- if (isset($this->reviewingMetadata[$field]) && $this->reviewingMetadata[$field] !== null) {
- $updateData[$field] = $this->reviewingMetadata[$field];
- }
- }
+ $updateData = $this->buildUpdateData();
- if (! empty($updateData)) {
+ if ($updateData !== []) {
$book->update($updateData);
}
- $this->updateLocalBookData($this->reviewingBookId, $updateData);
+ $this->updateLocalItemData($this->reviewingBookId, $updateData);
$this->closeReviewModal();
@@ -295,35 +309,6 @@ public function skipBook(): void
$this->closeReviewModal();
}
- public function closeReviewModal(): void
- {
- $this->showReviewModal = false;
- $this->reviewingBookId = null;
- $this->reviewingBook = null;
- $this->reviewingMetadata = null;
- $this->selectedFields = [];
- }
-
- protected function updateLocalBookData(int $bookId, array $updateData): void
- {
- foreach ($this->booksNeedingEnrichment as $index => $bookData) {
- if ($bookData['id'] === $bookId) {
- foreach ($updateData as $field => $value) {
- $this->booksNeedingEnrichment[$index]['current'][$field] = $value;
-
- $missingIndex = array_search($field, $this->booksNeedingEnrichment[$index]['missing']);
- if ($missingIndex !== false) {
- unset($this->booksNeedingEnrichment[$index]['missing'][$missingIndex]);
- $this->booksNeedingEnrichment[$index]['missing'] = array_values($this->booksNeedingEnrichment[$index]['missing']);
- }
- }
-
- $this->booksNeedingEnrichment[$index]['has_missing'] = ! empty($this->booksNeedingEnrichment[$index]['missing']);
- break;
- }
- }
- }
-
public function getSourceLabel(string $source): string
{
return match ($source) {
@@ -333,34 +318,14 @@ public function getSourceLabel(string $source): string
};
}
- public function getBooksWithMissingCount(): int
- {
- return collect($this->booksNeedingEnrichment)->where('has_missing', true)->count();
- }
-
- public function getFetchedCount(): int
- {
- return count($this->fetchedData);
- }
-
- public function isJobRunning(): bool
- {
- return $this->jobStatus && $this->jobStatus['status'] === 'running';
- }
-
- public function isJobCompleted(): bool
- {
- return $this->jobStatus && $this->jobStatus['status'] === 'completed';
- }
-
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
// Auto-refresh job status if running
if ($this->jobStatus && $this->jobStatus['status'] === 'running') {
$this->refreshJobStatus();
}
- return view('livewire.books.metadata-enrichment')
- ->layout('layouts.app');
+ return view('livewire.books.metadata-enrichment');
}
}
diff --git a/app/Livewire/Books/ReadQueue.php b/app/Livewire/Books/ReadQueue.php
index 612c0a9..40ecdc5 100644
--- a/app/Livewire/Books/ReadQueue.php
+++ b/app/Livewire/Books/ReadQueue.php
@@ -6,7 +6,9 @@
use App\Enums\ReadingStatus;
use App\Models\Book;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class ReadQueue extends Component
@@ -22,7 +24,8 @@ public function addToQueue(Book $book): void
// Get the next position
$maxPosition = Book::where('user_id', Auth::id())
->whereNotNull('queue_position')
- ->max('queue_position') ?? 0;
+ ->max('queue_position');
+ $maxPosition = is_numeric($maxPosition) ? (int) $maxPosition : 0;
$book->update(['queue_position' => $maxPosition + 1]);
}
@@ -124,7 +127,7 @@ public function updateStatus(Book $book, string $status): void
$book->update([
'status' => $status,
- 'date_started' => $status === 'reading' && !$book->date_started ? now() : $book->date_started,
+ 'date_started' => $status === 'reading' && ! $book->date_started ? now() : $book->date_started,
]);
// Auto-remove from queue when marked as read
@@ -133,7 +136,8 @@ public function updateStatus(Book $book, string $status): void
}
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
$queuedBooks = Book::where('user_id', Auth::id())
->whereNotNull('queue_position')
@@ -144,6 +148,6 @@ public function render()
return view('livewire.books.read-queue', [
'books' => $queuedBooks,
'statuses' => ReadingStatus::cases(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Comics/ComicForm.php b/app/Livewire/Comics/ComicForm.php
index f18c986..d04410d 100644
--- a/app/Livewire/Comics/ComicForm.php
+++ b/app/Livewire/Comics/ComicForm.php
@@ -6,9 +6,11 @@
use App\Enums\ReadingStatus;
use App\Models\Comic;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class ComicForm extends Component
@@ -75,6 +77,9 @@ public function mount(?Comic $comic = null): void
}
}
+ /**
+ * @return array
+ */
public function rules(): array
{
return [
@@ -97,9 +102,9 @@ public function rules(): array
];
}
- protected function parseDateInput(?string $date): ?string
+ protected function parseDateInput(mixed $date): ?string
{
- if (empty($date)) {
+ if (! is_string($date) || $date === '') {
return null;
}
@@ -123,15 +128,15 @@ protected function parseDateInput(?string $date): ?string
public function save(): void
{
$validated = $this->validate();
+ $validated = is_array($validated) ? $validated : [];
$validated['date_started'] = $this->parseDateInput($validated['date_started'] ?? null);
$validated['date_finished'] = $this->parseDateInput($validated['date_finished'] ?? null);
- if ($validated['date_started'] && $validated['date_finished']) {
- if ($validated['date_finished'] < $validated['date_started']) {
- $this->addError('date_finished', 'Date finished must be after or equal to date started.');
- return;
- }
+ if ($validated['date_started'] && $validated['date_finished'] && $validated['date_finished'] < $validated['date_started']) {
+ $this->addError('date_finished', 'Date finished must be after or equal to date started.');
+
+ return;
}
$data = [
@@ -170,14 +175,15 @@ public function save(): void
public function isEditing(): bool
{
- return $this->comic !== null && $this->comic->exists;
+ return $this->comic instanceof Comic && $this->comic->exists;
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.comics.comic-form', [
'statuses' => ReadingStatus::cases(),
'isEditing' => $this->isEditing(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Comics/ComicImport.php b/app/Livewire/Comics/ComicImport.php
index fba455c..6f55881 100644
--- a/app/Livewire/Comics/ComicImport.php
+++ b/app/Livewire/Comics/ComicImport.php
@@ -4,28 +4,40 @@
namespace App\Livewire\Comics;
+use App\Models\User;
use App\Services\ComicImportService;
+use Exception;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
+use InvalidArgumentException;
+use Livewire\Attributes\Layout;
use Livewire\Component;
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
+use RuntimeException;
class ComicImport extends Component
{
use WithFileUploads;
- public $file;
+ public ?TemporaryUploadedFile $file = null;
public string $format = 'csv'; // csv or json
public bool $skipDuplicates = true;
+ /** @var Collection>|null */
public ?Collection $preview = null;
+ /** @var array{imported: int, skipped: int, errors: list}|null */
public ?array $importResult = null;
public bool $importing = false;
+ /**
+ * @return array
+ */
protected function rules(): array
{
$mimes = $this->format === 'json' ? 'json,txt' : 'csv,txt';
@@ -50,19 +62,19 @@ public function updatedFormat(): void
protected function generatePreview(): void
{
- $content = file_get_contents($this->file->getRealPath());
+ $content = $this->uploadedContent();
+
+ if ($content === null) {
+ return;
+ }
try {
$service = new ComicImportService;
- if ($this->format === 'json') {
- $comics = $service->parseJson($content);
- } else {
- $comics = $service->parseCSV($content);
- }
+ $comics = $this->format === 'json' ? $service->parseJson($content) : $service->parseCSV($content);
$this->preview = $comics->take(10);
- } catch (\InvalidArgumentException $e) {
+ } catch (InvalidArgumentException $e) {
$this->addError('file', $e->getMessage());
$this->file = null;
$this->preview = null;
@@ -75,21 +87,22 @@ public function import(): void
$this->importing = true;
try {
- $content = file_get_contents($this->file->getRealPath());
- $service = new ComicImportService;
+ $content = $this->uploadedContent();
- if ($this->format === 'json') {
- $comics = $service->parseJson($content);
- } else {
- $comics = $service->parseCSV($content);
+ if ($content === null) {
+ throw new RuntimeException('Could not read the uploaded file.');
}
+ $service = new ComicImportService;
+
+ $comics = $this->format === 'json' ? $service->parseJson($content) : $service->parseCSV($content);
+
$this->importResult = $service->importComics(
- Auth::user(),
+ $this->currentUser(),
$comics,
$this->skipDuplicates
);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->importResult = [
'imported' => 0,
'skipped' => 0,
@@ -110,8 +123,33 @@ public function resetForm(): void
$this->format = 'csv';
}
- public function render()
+ private function uploadedContent(): ?string
+ {
+ $path = $this->file?->getRealPath();
+
+ if (! is_string($path) || $path === '') {
+ return null;
+ }
+
+ $content = file_get_contents($path);
+
+ return $content === false ? null : $content;
+ }
+
+ private function currentUser(): User
+ {
+ $user = Auth::user();
+
+ if (! $user instanceof User) {
+ abort(403);
+ }
+
+ return $user;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
{
- return view('livewire.comics.comic-import')->layout('layouts.app');
+ return view('livewire.comics.comic-import');
}
}
diff --git a/app/Livewire/Comics/ComicIndex.php b/app/Livewire/Comics/ComicIndex.php
index 717cb14..84d9d85 100644
--- a/app/Livewire/Comics/ComicIndex.php
+++ b/app/Livewire/Comics/ComicIndex.php
@@ -5,34 +5,22 @@
namespace App\Livewire\Comics;
use App\Enums\ReadingStatus;
+use App\Livewire\Concerns\WithAccentInsensitiveSearch;
+use App\Livewire\Concerns\WithIndexFiltering;
use App\Models\Comic;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Str;
+use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
class ComicIndex extends Component
{
+ use WithAccentInsensitiveSearch;
+ use WithIndexFiltering;
use WithPagination;
- private function normalizeForSearch(string $string): string
- {
- return Str::ascii($string);
- }
-
- private function matchesSearch(?string $value, string $normalizedSearch): bool
- {
- if ($value === null) {
- return false;
- }
-
- return str_contains(
- strtolower($this->normalizeForSearch($value)),
- strtolower($normalizedSearch)
- );
- }
-
public string $search = '';
public string $status = '';
@@ -45,12 +33,14 @@ private function matchesSearch(?string $value, string $normalizedSearch): bool
public string $viewMode = 'gallery';
+ /** @var array */
public array $selected = [];
public bool $selectAll = false;
private const ALLOWED_SORT_COLUMNS = ['title', 'rating', 'issue_count', 'start_year', 'date_finished', 'updated_at', 'publisher', 'date_started'];
+ /** @var array */
protected $queryString = [
'search' => ['except' => ''],
'status' => ['except' => ''],
@@ -65,8 +55,14 @@ public function updatingSearch(): void
$this->resetPage();
}
- public function updatingStatus(): void
+ public function updatingStatus(string $value): void
{
+ if ($value === 'want_to_read' && in_array($this->sortBy, ['date_finished', 'date_started'])) {
+ $this->sortBy = 'updated_at';
+ } elseif ($value === 'reading' && $this->sortBy === 'date_finished') {
+ $this->sortBy = 'date_started';
+ }
+
$this->resetPage();
}
@@ -75,31 +71,6 @@ public function updatingPublisher(): void
$this->resetPage();
}
- public function setViewMode(string $mode): void
- {
- $this->viewMode = in_array($mode, ['gallery', 'list']) ? $mode : 'gallery';
- }
-
- public function sort(string $column): void
- {
- if ($this->sortBy === $column) {
- $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
- } else {
- $this->sortBy = $column;
- $this->sortDirection = 'asc';
- }
- }
-
- private function safeSortDirection(): string
- {
- return $this->sortDirection === 'asc' ? 'asc' : 'desc';
- }
-
- private function safeSortBy(): string
- {
- return in_array($this->sortBy, self::ALLOWED_SORT_COLUMNS, true) ? $this->sortBy : 'updated_at';
- }
-
public function updateStatus(Comic $comic, string $status): void
{
$this->authorize('update', $comic);
@@ -125,22 +96,18 @@ public function updatedSelectAll(bool $value): void
if ($value) {
$query = Comic::query()
->where('user_id', Auth::id())
- ->when($this->status, function ($query) {
+ ->when($this->status, function ($query): void {
$query->where('status', $this->status);
})
- ->when($this->publisher, function ($query) {
+ ->when($this->publisher, function ($query): void {
$query->where('publisher', $this->publisher);
});
if ($this->search) {
- $normalizedSearch = $this->normalizeForSearch($this->search);
- $allComics = $query->get();
- $this->selected = $allComics->filter(function ($comic) use ($normalizedSearch) {
- return $this->matchesSearch($comic->title, $normalizedSearch)
- || $this->matchesSearch($comic->publisher, $normalizedSearch);
- })->pluck('id')->map(fn ($id) => (string) $id)->toArray();
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'publisher']);
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
} else {
- $this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
}
} else {
$this->selected = [];
@@ -149,10 +116,11 @@ public function updatedSelectAll(bool $value): void
public function deleteSelected(): void
{
- $count = Comic::query()
+ $deleted = Comic::query()
->where('user_id', Auth::id())
->whereIn('id', $this->selected)
->delete();
+ $count = is_int($deleted) ? $deleted : 0;
$this->selected = [];
$this->selectAll = false;
@@ -160,12 +128,8 @@ public function deleteSelected(): void
session()->flash('message', "{$count} comic(s) deleted successfully.");
}
- public function paginationView(): string
- {
- return 'livewire.custom-pagination';
- }
-
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
$perPage = $this->viewMode === 'list' ? 25 : 18;
$sortBy = $this->safeSortBy();
@@ -173,10 +137,10 @@ public function render()
$query = Comic::query()
->where('user_id', Auth::id())
- ->when($this->status, function ($query) {
+ ->when($this->status, function ($query): void {
$query->where('status', $this->status);
})
- ->when($this->publisher, function ($query) {
+ ->when($this->publisher, function ($query): void {
$query->where('publisher', $this->publisher);
});
@@ -196,48 +160,14 @@ public function render()
$query->orderBy($sortBy, $sortDir);
}
- if ($this->search) {
- $normalizedSearch = $this->normalizeForSearch($this->search);
-
- $exactMatchIds = (clone $query)
- ->where(function ($q) {
- $q->where('title', 'like', '%'.$this->search.'%')
- ->orWhere('publisher', 'like', '%'.$this->search.'%');
- })
- ->pluck('id');
-
- $allComics = $query->get();
- $filteredIds = $allComics->filter(function ($comic) use ($normalizedSearch) {
- return $this->matchesSearch($comic->title, $normalizedSearch)
- || $this->matchesSearch($comic->publisher, $normalizedSearch);
- })->pluck('id');
-
- $matchingIds = $exactMatchIds->merge($filteredIds)->unique();
-
- $searchQuery = Comic::query()
- ->whereIn('id', $matchingIds);
-
- if (in_array($sortBy, ['issue_count', 'start_year'])) {
- if ($sortDir === 'asc') {
- $searchQuery->orderByRaw("{$sortBy} IS NOT NULL")
- ->orderByRaw("CASE WHEN {$sortBy} IS NULL THEN title END ASC")
- ->orderBy($sortBy, 'asc');
- } else {
- $searchQuery->orderByRaw("{$sortBy} IS NULL")
- ->orderBy($sortBy, 'desc')
- ->orderByRaw("CASE WHEN {$sortBy} IS NULL THEN title END DESC");
- }
- } elseif ($sortBy === 'date_finished') {
- $searchQuery->orderBy(DB::raw('COALESCE(date_finished, updated_at)'), $sortDir);
- } else {
- $searchQuery->orderBy($sortBy, $sortDir);
- }
+ $query->orderBy('id');
- $comics = $searchQuery->paginate($perPage);
- } else {
- $comics = $query->paginate($perPage);
+ if ($this->search) {
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'publisher']);
}
+ $comics = $query->paginate($perPage);
+
$publishers = Comic::query()
->where('user_id', Auth::id())
->whereNotNull('publisher')
@@ -249,6 +179,6 @@ public function render()
'comics' => $comics,
'statuses' => ReadingStatus::cases(),
'publishers' => $publishers,
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Comics/ComicIssueShow.php b/app/Livewire/Comics/ComicIssueShow.php
index 8af61cf..3b61691 100644
--- a/app/Livewire/Comics/ComicIssueShow.php
+++ b/app/Livewire/Comics/ComicIssueShow.php
@@ -7,7 +7,9 @@
use App\Enums\ReadingStatus;
use App\Models\Comic;
use App\Models\ComicIssue;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class ComicIssueShow extends Component
@@ -15,12 +17,13 @@ class ComicIssueShow extends Component
use AuthorizesRequests;
public Comic $comic;
+
public ComicIssue $issue;
public function mount(Comic $comic, ComicIssue $issue): void
{
$this->authorize('view', $comic);
-
+
if ($issue->comic_id !== $comic->id) {
abort(404);
}
@@ -58,10 +61,11 @@ public function saveNotes(string $notes): void
session()->flash('message', 'Notes saved.');
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.comics.comic-issue-show', [
'statuses' => ReadingStatus::cases(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Comics/ComicSettings.php b/app/Livewire/Comics/ComicSettings.php
index c56905a..f0bb8ea 100644
--- a/app/Livewire/Comics/ComicSettings.php
+++ b/app/Livewire/Comics/ComicSettings.php
@@ -6,7 +6,9 @@
use App\Models\Comic;
use App\Services\ComicVineService;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class ComicSettings extends Component
@@ -38,9 +40,10 @@ public function deleteAllComics(): void
return;
}
- $count = Comic::query()
+ $deleted = Comic::query()
->where('user_id', Auth::id())
->delete();
+ $count = is_int($deleted) ? $deleted : 0;
$this->showDeleteAllModal = false;
$this->confirmationInput = '';
@@ -71,12 +74,13 @@ protected function generateConfirmationWord(): string
return implode('', $chars);
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
$comicVine = app(ComicVineService::class);
return view('livewire.comics.comic-settings', [
'apiConfigured' => $comicVine->isConfigured(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Comics/ComicShow.php b/app/Livewire/Comics/ComicShow.php
index da94e38..df18ab1 100644
--- a/app/Livewire/Comics/ComicShow.php
+++ b/app/Livewire/Comics/ComicShow.php
@@ -8,7 +8,9 @@
use App\Models\Comic;
use App\Models\ComicIssue;
use App\Services\ComicVineService;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class ComicShow extends Component
@@ -21,8 +23,13 @@ class ComicShow extends Component
// Properties for selective import
public bool $showImportModal = false;
+
+ /** @var array> */
public array $availableIssues = [];
+
+ /** @var list */
public array $selectedIssueIds = [];
+
public bool $selectAll = true;
public function mount(Comic $comic): void
@@ -63,6 +70,7 @@ public function fetchIssues(): void
if (! $this->comic->comicvine_volume_id) {
session()->flash('error', 'This comic has no Comic Vine volume ID.');
+
return;
}
@@ -71,9 +79,10 @@ public function fetchIssues(): void
$comicVine = app(ComicVineService::class);
$this->availableIssues = $comicVine->fetchVolumeIssues($this->comic->comicvine_volume_id);
- if (empty($this->availableIssues)) {
+ if ($this->availableIssues === []) {
session()->flash('error', 'No issues found or could not fetch from Comic Vine.');
$this->fetchingIssues = false;
+
return;
}
@@ -83,13 +92,12 @@ public function fetchIssues(): void
->all();
// Filter out issues that already exist in our database
- $this->availableIssues = array_filter($this->availableIssues, function($issue) use ($existingIds) {
- return !in_array($issue['issue_id'], $existingIds);
- });
+ $this->availableIssues = array_filter($this->availableIssues, fn (array $issue): bool => ! in_array($issue['issue_id'], $existingIds));
- if (empty($this->availableIssues)) {
+ if ($this->availableIssues === []) {
session()->flash('message', 'All issues from Comic Vine are already in your library.');
$this->fetchingIssues = false;
+
return;
}
@@ -100,21 +108,18 @@ public function fetchIssues(): void
$this->fetchingIssues = false;
}
- public function updatedSelectAll($value): void
+ public function updatedSelectAll(mixed $value): void
{
- if ($value) {
- $this->selectedIssueIds = array_column($this->availableIssues, 'issue_id');
- } else {
- $this->selectedIssueIds = [];
- }
+ $this->selectedIssueIds = $value ? array_column($this->availableIssues, 'issue_id') : [];
}
public function importSelectedIssues(): void
{
$this->authorize('update', $this->comic);
- if (empty($this->selectedIssueIds)) {
+ if ($this->selectedIssueIds === []) {
$this->showImportModal = false;
+
return;
}
@@ -184,7 +189,8 @@ public function deleteComic(): void
$this->redirect(route('comics.index'));
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
$issues = $this->comic->issues()
->orderByRaw("CAST(NULLIF(issue_number, '') AS NUMERIC) ASC")
@@ -199,6 +205,6 @@ public function render()
'read' => $issues->where('status', ReadingStatus::Read)->count(),
'reading' => $issues->where('status', ReadingStatus::Reading)->count(),
],
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Comics/ComicVineSearch.php b/app/Livewire/Comics/ComicVineSearch.php
index 813d68c..87bc2d4 100644
--- a/app/Livewire/Comics/ComicVineSearch.php
+++ b/app/Livewire/Comics/ComicVineSearch.php
@@ -8,7 +8,9 @@
use App\Models\Comic;
use App\Models\ComicIssue;
use App\Services\ComicVineService;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class ComicVineSearch extends Component
@@ -17,24 +19,39 @@ class ComicVineSearch extends Component
// Search state
public string $query = '';
+
+ /** @var list> */
public array $searchResults = [];
// Selected volume details
public string $title = '';
+
public string $publisher = '';
+
public ?int $start_year = null;
+
public ?int $issue_count = null;
+
public string $description = '';
+
public string $cover_url = '';
+
public string $comicvine_volume_id = '';
+
public string $comicvine_url = '';
+
public string $creators = '';
+
public string $characters = '';
+
public string $status = 'want_to_read';
+
public ?int $rating = null;
+
public bool $fetchIssues = true;
// Duplicate detection
+ /** @var array */
public array $existingVolumeIds = [];
public function mount(): void
@@ -64,25 +81,36 @@ public function selectResult(string $volumeId): void
if (! $details) {
session()->flash('error', 'Could not fetch volume details from Comic Vine.');
+
return;
}
- $this->title = $details['title'] ?? '';
- $this->publisher = $details['publisher'] ?? '';
- $this->start_year = $details['start_year'];
- $this->issue_count = $details['issue_count'];
- $this->description = $details['description'] ?? '';
- $this->cover_url = $details['cover_url'] ?? '';
- $this->comicvine_volume_id = $details['volume_id'] ?? '';
- $this->comicvine_url = $details['comicvine_url'] ?? '';
- $this->creators = $details['creators'] ?? '';
- $this->characters = $details['characters'] ?? '';
+ $this->title = $this->strOf($details['title'] ?? null);
+ $this->publisher = $this->strOf($details['publisher'] ?? null);
+ $this->start_year = $this->intOrNull($details['start_year'] ?? null);
+ $this->issue_count = $this->intOrNull($details['issue_count'] ?? null);
+ $this->description = $this->strOf($details['description'] ?? null);
+ $this->cover_url = $this->strOf($details['cover_url'] ?? null);
+ $this->comicvine_volume_id = $this->strOf($details['volume_id'] ?? null);
+ $this->comicvine_url = $this->strOf($details['comicvine_url'] ?? null);
+ $this->creators = $this->strOf($details['creators'] ?? null);
+ $this->characters = $this->strOf($details['characters'] ?? null);
$this->status = 'want_to_read';
$this->rating = null;
$this->step = 'configure';
}
+ private function strOf(mixed $value): string
+ {
+ return is_string($value) ? $value : '';
+ }
+
+ private function intOrNull(mixed $value): ?int
+ {
+ return is_numeric($value) ? (int) $value : null;
+ }
+
public function addComic(): void
{
$comic = Comic::create([
@@ -149,10 +177,11 @@ public function backToResults(): void
$this->step = 'results';
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.comics.comic-vine-search', [
'statuses' => ReadingStatus::cases(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Concerns/WithAccentInsensitiveSearch.php b/app/Livewire/Concerns/WithAccentInsensitiveSearch.php
new file mode 100644
index 0000000..b3d58cd
--- /dev/null
+++ b/app/Livewire/Concerns/WithAccentInsensitiveSearch.php
@@ -0,0 +1,32 @@
+ $query
+ * @param list $columns
+ */
+ private function applyAccentInsensitiveSearch(Builder $query, string $search, array $columns): void
+ {
+ $grammar = DB::connection()->getQueryGrammar();
+ $words = preg_split('/\s+/', trim($search)) ?: [];
+
+ foreach ($words as $word) {
+ $query->where(function (Builder $q) use ($word, $columns, $grammar): void {
+ foreach ($columns as $column) {
+ $q->orWhereRaw('unaccent(COALESCE('.$grammar->wrap($column).", '')) ILIKE unaccent(?)", ['%'.$word.'%']);
+ }
+ });
+ }
+ }
+}
diff --git a/app/Livewire/Concerns/WithIndexFiltering.php b/app/Livewire/Concerns/WithIndexFiltering.php
new file mode 100644
index 0000000..b8e5ee7
--- /dev/null
+++ b/app/Livewire/Concerns/WithIndexFiltering.php
@@ -0,0 +1,69 @@
+sortBy === $column) {
+ $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
+ } else {
+ $this->sortBy = $column;
+ $this->sortDirection = 'asc';
+ }
+ }
+
+ protected function safeSortDirection(): string
+ {
+ return $this->sortDirection === 'asc' ? 'asc' : 'desc';
+ }
+
+ protected function safeSortBy(): string
+ {
+ return in_array($this->sortBy, self::ALLOWED_SORT_COLUMNS, true)
+ ? $this->sortBy
+ : $this->defaultSortColumn();
+ }
+
+ public function setViewMode(string $mode): void
+ {
+ $this->viewMode = in_array($mode, ['gallery', 'list'], true)
+ ? $mode
+ : $this->defaultViewMode();
+ }
+
+ /**
+ * Default sort column when the requested one is not allowed.
+ * Using classes may override to customise.
+ */
+ protected function defaultSortColumn(): string
+ {
+ return 'updated_at';
+ }
+
+ /**
+ * Default view mode when the requested one is invalid.
+ * Using classes may override to customise.
+ */
+ protected function defaultViewMode(): string
+ {
+ return 'gallery';
+ }
+
+ public function paginationView(): string
+ {
+ return 'livewire.custom-pagination';
+ }
+}
diff --git a/app/Livewire/Concerns/WithMetadataEnrichment.php b/app/Livewire/Concerns/WithMetadataEnrichment.php
new file mode 100644
index 0000000..a611f94
--- /dev/null
+++ b/app/Livewire/Concerns/WithMetadataEnrichment.php
@@ -0,0 +1,197 @@
+>
+ */
+ abstract protected function enrichmentList(): array;
+
+ /**
+ * Replace the items needing enrichment for this media type.
+ *
+ * @param array> $list
+ */
+ abstract protected function setEnrichmentList(array $list): void;
+
+ /**
+ * Set the id of the item currently being reviewed.
+ */
+ abstract protected function setReviewingId(?int $id): void;
+
+ /**
+ * Set the data of the item currently being reviewed.
+ *
+ * @param array|null $item
+ */
+ abstract protected function setReviewingItem(?array $item): void;
+
+ /**
+ * Return the list of fields that can be enriched for this media type.
+ *
+ * @return list
+ */
+ abstract protected function enrichableFields(): array;
+
+ /**
+ * Determine which fetched fields should be pre-selected for applying,
+ * based on source priority and whether current values exist.
+ *
+ * @param array $itemData
+ * @param array|null $metadata
+ * @return list
+ */
+ protected function getFieldsToApply(array $itemData, ?array $metadata): array
+ {
+ if ($metadata === null) {
+ return [];
+ }
+
+ $fields = [];
+ $currentFirst = ($this->sourcePriority[0] ?? null) === 'current';
+ $current = is_array($itemData['current'] ?? null) ? $itemData['current'] : [];
+
+ foreach ($this->enrichableFields() as $field) {
+ $hasCurrentValue = ! empty($current[$field]);
+ $hasNewValue = ! empty($metadata[$field]);
+
+ if (! $hasNewValue) {
+ continue;
+ }
+
+ if (! $hasCurrentValue || ! $currentFirst) {
+ $fields[] = $field;
+ }
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Open the review modal for a given item.
+ */
+ protected function openReviewFor(int $id): void
+ {
+ $itemData = collect($this->enrichmentList())->firstWhere('id', $id);
+
+ if (! is_array($itemData)) {
+ return;
+ }
+
+ $metadata = $this->fetchedData[$id] ?? null;
+ $metadata = is_array($metadata) ? $metadata : null;
+
+ $this->setReviewingId($id);
+ $this->setReviewingItem($itemData);
+ $this->reviewingMetadata = $metadata;
+ $this->selectedFields = $this->getFieldsToApply($itemData, $metadata);
+ $this->showReviewModal = true;
+ }
+
+ /**
+ * Close the review modal and reset state.
+ */
+ public function closeReviewModal(): void
+ {
+ $this->showReviewModal = false;
+ $this->setReviewingId(null);
+ $this->setReviewingItem(null);
+ $this->reviewingMetadata = null;
+ $this->selectedFields = [];
+ }
+
+ /**
+ * Build the update data array from selected fields and reviewing metadata.
+ *
+ * @return array
+ */
+ protected function buildUpdateData(): array
+ {
+ $updateData = [];
+ $metadata = $this->reviewingMetadata ?? [];
+
+ foreach ($this->selectedFields as $field) {
+ if (isset($metadata[$field])) {
+ $updateData[$field] = $metadata[$field];
+ }
+ }
+
+ return $updateData;
+ }
+
+ /**
+ * Update the local enrichment list after metadata has been applied,
+ * removing filled fields from the missing list.
+ *
+ * @param array $updateData
+ */
+ protected function updateLocalItemData(int $itemId, array $updateData): void
+ {
+ $list = $this->enrichmentList();
+
+ foreach ($list as $index => $itemData) {
+ if (($itemData['id'] ?? null) !== $itemId) {
+ continue;
+ }
+
+ $current = is_array($itemData['current'] ?? null) ? $itemData['current'] : [];
+ $missing = is_array($itemData['missing'] ?? null) ? array_values($itemData['missing']) : [];
+
+ foreach ($updateData as $field => $value) {
+ $current[$field] = $value;
+
+ $missingIndex = array_search($field, $missing, true);
+ if ($missingIndex !== false) {
+ unset($missing[$missingIndex]);
+ $missing = array_values($missing);
+ }
+ }
+
+ $itemData['current'] = $current;
+ $itemData['missing'] = $missing;
+ $itemData['has_missing'] = $missing !== [];
+ $list[$index] = $itemData;
+ break;
+ }
+
+ $this->setEnrichmentList($list);
+ }
+
+ /**
+ * Get the count of items with missing metadata.
+ */
+ public function getItemsWithMissingCount(): int
+ {
+ return collect($this->enrichmentList())->where('has_missing', true)->count();
+ }
+
+ /**
+ * Get the count of items that have had metadata fetched.
+ */
+ public function getFetchedCount(): int
+ {
+ return count($this->fetchedData);
+ }
+
+ /**
+ * Check if a background job is currently running.
+ */
+ public function isJobRunning(): bool
+ {
+ return $this->jobStatus !== null && ($this->jobStatus['status'] ?? null) === 'running';
+ }
+
+ /**
+ * Check if a background job has completed.
+ */
+ public function isJobCompleted(): bool
+ {
+ return $this->jobStatus !== null && ($this->jobStatus['status'] ?? null) === 'completed';
+ }
+}
diff --git a/app/Livewire/Concerns/WithSourcePriority.php b/app/Livewire/Concerns/WithSourcePriority.php
new file mode 100644
index 0000000..b3240cd
--- /dev/null
+++ b/app/Livewire/Concerns/WithSourcePriority.php
@@ -0,0 +1,32 @@
+sourcePriority, true);
+ if ($index === false || $index === 0) {
+ return;
+ }
+
+ $reordered = $this->sourcePriority;
+ [$reordered[$index - 1], $reordered[$index]] = [$reordered[$index], $reordered[$index - 1]];
+ $this->sourcePriority = array_values($reordered);
+ }
+
+ public function moveSourceDown(string $source): void
+ {
+ $index = array_search($source, $this->sourcePriority, true);
+ if ($index === false || $index >= count($this->sourcePriority) - 1) {
+ return;
+ }
+
+ $reordered = $this->sourcePriority;
+ [$reordered[$index + 1], $reordered[$index]] = [$reordered[$index], $reordered[$index + 1]];
+ $this->sourcePriority = array_values($reordered);
+ }
+}
diff --git a/app/Livewire/Concerts/ConcertForm.php b/app/Livewire/Concerts/ConcertForm.php
new file mode 100644
index 0000000..662a4eb
--- /dev/null
+++ b/app/Livewire/Concerts/ConcertForm.php
@@ -0,0 +1,157 @@
+exists) {
+ $this->authorize('update', $concert);
+ $this->concert = $concert;
+ $this->fill([
+ 'artist' => $concert->artist,
+ 'tour_name' => $concert->tour_name ?? '',
+ 'venue' => $concert->venue ?? '',
+ 'city' => $concert->city ?? '',
+ 'country' => $concert->country ?? '',
+ 'event_date' => $concert->event_date?->format('d/m/Y'),
+ 'cover_url' => $concert->cover_url ?? '',
+ 'status' => $concert->status->value,
+ 'rating' => $concert->rating,
+ 'setlist_fm_id' => $concert->setlist_fm_id,
+ 'artist_mbid' => $concert->artist_mbid,
+ 'notes' => $concert->notes ?? '',
+ ]);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function rules(): array
+ {
+ return [
+ 'artist' => ['required', 'string', 'max:255'],
+ 'tour_name' => ['nullable', 'string', 'max:255'],
+ 'venue' => ['nullable', 'string', 'max:255'],
+ 'city' => ['nullable', 'string', 'max:255'],
+ 'country' => ['nullable', 'string', 'max:255'],
+ 'event_date' => ['nullable', 'date_format:d/m/Y'],
+ 'cover_url' => ['nullable', 'url', 'max:2048'],
+ 'status' => ['required', Rule::enum(ListeningStatus::class)],
+ 'rating' => ['nullable', 'integer', 'min:1', 'max:10'],
+ 'setlist_fm_id' => ['nullable', 'string', 'max:255'],
+ 'artist_mbid' => ['nullable', 'string', 'max:255'],
+ 'notes' => ['nullable', 'string', 'max:10000'],
+ ];
+ }
+
+ protected function parseDateInput(?string $date): ?string
+ {
+ if (empty($date)) {
+ return null;
+ }
+
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
+ return $date;
+ }
+
+ if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $date, $matches)) {
+ return checkdate((int) $matches[2], (int) $matches[1], (int) $matches[3])
+ ? "{$matches[3]}-{$matches[2]}-{$matches[1]}"
+ : null;
+ }
+
+ return null;
+ }
+
+ public function save(): void
+ {
+ $validated = $this->validate();
+ $validated = is_array($validated) ? $validated : [];
+
+ $validated['event_date'] = $this->parseDateInput(is_string($validated['event_date'] ?? null) ? $validated['event_date'] : null);
+
+ $data = [
+ 'artist' => $validated['artist'],
+ 'tour_name' => $validated['tour_name'] ?: null,
+ 'venue' => $validated['venue'] ?: null,
+ 'city' => $validated['city'] ?: null,
+ 'country' => $validated['country'] ?: null,
+ 'event_date' => $validated['event_date'],
+ 'cover_url' => $validated['cover_url'] ?: null,
+ 'status' => $validated['status'],
+ 'rating' => $validated['rating'],
+ 'setlist_fm_id' => $validated['setlist_fm_id'] ?: null,
+ 'artist_mbid' => $validated['artist_mbid'] ?: null,
+ 'notes' => $validated['notes'] ?: null,
+ ];
+
+ if ($this->concert) {
+ $this->concert->update($data);
+ $message = 'Concert updated successfully.';
+ } else {
+ $data['user_id'] = Auth::id();
+ $this->concert = Concert::create($data);
+ $message = 'Concert created successfully.';
+ }
+
+ session()->flash('message', $message);
+
+ $this->redirect(route('concerts.show', $this->concert));
+ }
+
+ public function isEditing(): bool
+ {
+ return $this->concert instanceof Concert && $this->concert->exists;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.concerts.concert-form', [
+ 'statuses' => ListeningStatus::cases(),
+ 'isEditing' => $this->isEditing(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Concerts/ConcertIndex.php b/app/Livewire/Concerts/ConcertIndex.php
new file mode 100644
index 0000000..52bd4ae
--- /dev/null
+++ b/app/Livewire/Concerts/ConcertIndex.php
@@ -0,0 +1,145 @@
+ */
+ public array $selected = [];
+
+ private const ALLOWED_SORT_COLUMNS = [
+ 'artist', 'venue', 'city', 'event_date', 'rating', 'updated_at', 'created_at',
+ ];
+
+ /** @var array */
+ protected array $queryString = [
+ 'search' => ['except' => ''],
+ 'status' => ['except' => ''],
+ 'sortBy' => ['except' => 'event_date'],
+ 'sortDirection' => ['except' => 'desc'],
+ 'viewMode' => ['except' => 'list'],
+ ];
+
+ protected function defaultSortColumn(): string
+ {
+ return 'event_date';
+ }
+
+ protected function defaultViewMode(): string
+ {
+ return 'list';
+ }
+
+ public function updatingSearch(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatingStatus(): void
+ {
+ $this->resetPage();
+ }
+
+ public function deleteConcert(Concert $concert): void
+ {
+ $this->authorize('delete', $concert);
+
+ $concert->delete();
+
+ session()->flash('message', 'Concert deleted successfully.');
+ }
+
+ public function updatedSelectAll(bool $value): void
+ {
+ if ($value) {
+ $query = $this->buildQuery();
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
+ } else {
+ $this->selected = [];
+ }
+ }
+
+ public function deleteSelected(): void
+ {
+ Concert::whereIn('id', $this->selected)
+ ->where('user_id', Auth::id())
+ ->delete();
+
+ $count = count($this->selected);
+ $this->selected = [];
+ $this->selectAll = false;
+
+ session()->flash('message', "{$count} concert(s) deleted.");
+ }
+
+ /**
+ * @return Builder
+ */
+ private function buildQuery(): Builder
+ {
+ $query = Concert::where('user_id', Auth::id());
+
+ if ($this->search !== '') {
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['artist', 'venue', 'city', 'tour_name']);
+ }
+
+ if ($this->status !== '') {
+ $query->where('status', $this->status);
+ }
+
+ return $query;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ $perPage = $this->viewMode === 'list' ? 25 : 18;
+ $sortBy = $this->safeSortBy();
+ $sortDir = $this->safeSortDirection();
+
+ $query = $this->buildQuery();
+
+ if (in_array($sortBy, ['rating', 'event_date'])) {
+ $query->orderByRaw("\"$sortBy\" $sortDir NULLS LAST");
+ } else {
+ $query->orderBy($sortBy, $sortDir);
+ }
+ $query->orderBy('id');
+
+ $concerts = $query->paginate($perPage);
+
+ return view('livewire.concerts.concert-index', [
+ 'concerts' => $concerts,
+ 'statuses' => ListeningStatus::cases(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Concerts/ConcertSetlistFmSearch.php b/app/Livewire/Concerts/ConcertSetlistFmSearch.php
new file mode 100644
index 0000000..990aa44
--- /dev/null
+++ b/app/Livewire/Concerts/ConcertSetlistFmSearch.php
@@ -0,0 +1,126 @@
+> */
+ public array $artists = [];
+
+ public ?string $selectedArtistMbid = null;
+
+ public ?string $selectedArtistName = null;
+
+ /** @var array */
+ public array $setlists = [];
+
+ /** @var array|null */
+ public ?array $selectedSetlist = null;
+
+ public string $status = 'attended';
+
+ public ?int $rating = null;
+
+ public string $notes = '';
+
+ public function searchArtists(): void
+ {
+ if (empty(trim($this->searchQuery))) {
+ return;
+ }
+
+ $service = app(SetlistFmService::class);
+ $this->artists = $service->searchArtists($this->searchQuery);
+ $this->step = 'artists';
+ }
+
+ public function selectArtist(string $mbid, string $name): void
+ {
+ $this->selectedArtistMbid = $mbid;
+ $this->selectedArtistName = $name;
+
+ $service = app(SetlistFmService::class);
+ $result = $service->getArtistSetlists($mbid);
+ $this->setlists = is_array($result['setlists'] ?? null) ? $result['setlists'] : [];
+ $this->step = 'setlists';
+ }
+
+ public function selectSetlist(int $index): void
+ {
+ $selected = $this->setlists[$index] ?? null;
+ $this->selectedSetlist = is_array($selected) ? $selected : null;
+
+ if ($this->selectedSetlist) {
+ $this->step = 'configure';
+ }
+ }
+
+ public function save(): void
+ {
+ if (! $this->selectedSetlist) {
+ return;
+ }
+
+ $existing = Concert::where('user_id', Auth::id())
+ ->where('setlist_fm_id', $this->selectedSetlist['setlist_fm_id'])
+ ->exists();
+
+ if ($existing) {
+ session()->flash('error', 'This concert is already in your library.');
+
+ return;
+ }
+
+ $concert = Concert::create([
+ 'user_id' => Auth::id(),
+ 'artist' => $this->selectedSetlist['artist'],
+ 'artist_mbid' => $this->selectedSetlist['artist_mbid'],
+ 'tour_name' => $this->selectedSetlist['tour_name'],
+ 'venue' => $this->selectedSetlist['venue'],
+ 'city' => $this->selectedSetlist['city'],
+ 'country' => $this->selectedSetlist['country'],
+ 'event_date' => $this->selectedSetlist['event_date'],
+ 'setlist' => $this->selectedSetlist['setlist'],
+ 'setlist_fm_id' => $this->selectedSetlist['setlist_fm_id'],
+ 'status' => $this->status,
+ 'rating' => $this->rating,
+ 'notes' => $this->notes ?: null,
+ ]);
+
+ session()->flash('message', "{$concert->artist} concert added successfully.");
+
+ $this->redirect(route('concerts.show', $concert));
+ }
+
+ public function back(): void
+ {
+ $this->step = match ($this->step) {
+ 'configure' => 'setlists',
+ 'setlists' => 'artists',
+ 'artists' => 'search',
+ default => 'search',
+ };
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.concerts.concert-setlistfm-search', [
+ 'statuses' => ListeningStatus::cases(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Concerts/ConcertShow.php b/app/Livewire/Concerts/ConcertShow.php
new file mode 100644
index 0000000..a3ca7fd
--- /dev/null
+++ b/app/Livewire/Concerts/ConcertShow.php
@@ -0,0 +1,48 @@
+authorize('view', $concert);
+ $this->concert = $concert;
+ }
+
+ public function updateRating(int $rating): void
+ {
+ $this->authorize('update', $this->concert);
+
+ $newRating = $this->concert->rating === $rating ? null : $rating;
+ $this->concert->update(['rating' => $newRating]);
+ }
+
+ public function deleteConcert(): void
+ {
+ $this->authorize('delete', $this->concert);
+
+ $this->concert->delete();
+
+ session()->flash('message', 'Concert deleted successfully.');
+ $this->redirect(route('concerts.index'));
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.concerts.concert-show');
+ }
+}
diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php
index 1236b8d..20eb8b1 100644
--- a/app/Livewire/Dashboard.php
+++ b/app/Livewire/Dashboard.php
@@ -4,13 +4,24 @@
namespace App\Livewire;
+use App\Enums\CollectionStatus;
+use App\Enums\ListeningStatus;
+use App\Enums\PlayingStatus;
use App\Enums\ReadingStatus;
use App\Enums\WatchingStatus;
+use App\Models\User;
+use Illuminate\Contracts\View\View;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class Dashboard extends Component
{
+ /**
+ * @return list>
+ */
public function getCategories(): array
{
return [
@@ -24,7 +35,7 @@ public function getCategories(): array
],
[
'name' => 'Reading',
- 'icon' => 'squares-2x2',
+ 'icon' => 'book-open',
'description' => 'Books, Comics, Manga',
'route' => 'reading.index',
'active' => true,
@@ -32,97 +43,174 @@ public function getCategories(): array
],
[
'name' => 'Playing',
- 'icon' => 'puzzle-piece',
- 'description' => 'Video Games, Board Games',
- 'route' => null,
- 'active' => false,
+ 'icon' => 'game-controller',
+ 'description' => 'Video Games & Board Games',
+ 'route' => 'playing.index',
+ 'active' => true,
'color' => 'green',
],
[
'name' => 'Listening',
- 'icon' => 'musical-note',
- 'description' => 'Music, Podcasts, Audiobooks',
- 'route' => null,
- 'active' => false,
+ 'icon' => 'headphones',
+ 'description' => 'Concerts, Albums & Music',
+ 'route' => 'listening.index',
+ 'active' => true,
'color' => 'orange',
],
];
}
+ /**
+ * @return array
+ */
public function getReadingStats(): array
{
- $user = Auth::user();
+ $user = $this->currentUser();
$year = now()->year;
- $driver = \Illuminate\Support\Facades\DB::connection()->getDriverName();
- $yearSql = $driver === 'pgsql' ? "CAST(EXTRACT(YEAR FROM %s) AS INTEGER)" : "strftime('%%Y', %s)";
+ $driver = DB::connection()->getDriverName();
+ $yearSql = $driver === 'pgsql' ? 'CAST(EXTRACT(YEAR FROM %s) AS INTEGER)' : "strftime('%%Y', %s)";
$bookStats = $user->books()
- ->selectRaw("COUNT(*) as total")
- ->selectRaw("SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as currently_reading", [ReadingStatus::Reading->value])
- ->selectRaw("SUM(CASE WHEN status = ? AND " . sprintf($yearSql, 'date_recorded') . " = ? THEN 1 ELSE 0 END) as read_this_year", [ReadingStatus::Read->value, (string) $year])
+ ->selectRaw('COUNT(*) as total')
+ ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as currently_reading', [ReadingStatus::Reading->value])
+ ->selectRaw('SUM(CASE WHEN status = ? AND '.sprintf($yearSql, 'date_finished').' = ? THEN 1 ELSE 0 END) as read_this_year', [ReadingStatus::Read->value, (string) $year])
->first();
$comicStats = $user->comics()
- ->selectRaw("COUNT(*) as total")
- ->selectRaw("SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as currently_reading", [ReadingStatus::Reading->value])
- ->selectRaw("SUM(CASE WHEN status = ? AND " . sprintf($yearSql, 'date_finished') . " = ? THEN 1 ELSE 0 END) as read_this_year", [ReadingStatus::Read->value, (string) $year])
+ ->selectRaw('COUNT(*) as total')
+ ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as currently_reading', [ReadingStatus::Reading->value])
+ ->selectRaw('SUM(CASE WHEN status = ? AND '.sprintf($yearSql, 'date_finished').' = ? THEN 1 ELSE 0 END) as read_this_year', [ReadingStatus::Read->value, (string) $year])
->first();
return [
- 'currently_reading' => (int) $bookStats->currently_reading + (int) $comicStats->currently_reading,
- 'read_this_year' => (int) $bookStats->read_this_year + (int) $comicStats->read_this_year,
- 'total_books' => (int) $bookStats->total,
- 'total_comics' => (int) $comicStats->total,
+ 'currently_reading' => $this->intAttr($bookStats, 'currently_reading') + $this->intAttr($comicStats, 'currently_reading'),
+ 'read_this_year' => $this->intAttr($bookStats, 'read_this_year') + $this->intAttr($comicStats, 'read_this_year'),
+ 'total_books' => $this->intAttr($bookStats, 'total'),
+ 'total_comics' => $this->intAttr($comicStats, 'total'),
];
}
+ /**
+ * @return array
+ */
public function getWatchingStats(): array
{
- $user = Auth::user();
+ $user = $this->currentUser();
$year = now()->year;
- $driver = \Illuminate\Support\Facades\DB::connection()->getDriverName();
- $yearSql = $driver === 'pgsql' ? "CAST(EXTRACT(YEAR FROM %s) AS INTEGER)" : "strftime('%%Y', %s)";
+ $driver = DB::connection()->getDriverName();
+ $yearSql = $driver === 'pgsql' ? 'CAST(EXTRACT(YEAR FROM %s) AS INTEGER)' : "strftime('%%Y', %s)";
$stats = $user->movies()
- ->selectRaw("COUNT(*) as total")
- ->selectRaw("SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as currently_watching", [WatchingStatus::Watching->value])
- ->selectRaw("SUM(CASE WHEN status = ? AND " . sprintf($yearSql, 'date_watched') . " = ? THEN 1 ELSE 0 END) as watched_this_year", [WatchingStatus::Watched->value, (string) $year])
+ ->selectRaw('COUNT(*) as total')
+ ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as currently_watching', [WatchingStatus::Watching->value])
+ ->selectRaw('SUM(CASE WHEN status = ? AND '.sprintf($yearSql, 'date_watched').' = ? THEN 1 ELSE 0 END) as watched_this_year', [WatchingStatus::Watched->value, (string) $year])
->first();
return [
- 'currently_watching' => (int) $stats->currently_watching,
- 'watched_this_year' => (int) $stats->watched_this_year,
- 'total_movies' => (int) $stats->total,
+ 'currently_watching' => $this->intAttr($stats, 'currently_watching'),
+ 'watched_this_year' => $this->intAttr($stats, 'watched_this_year'),
+ 'total_movies' => $this->intAttr($stats, 'total'),
];
}
+ /**
+ * @return array
+ */
public function getAnimeStats(): array
{
- $user = Auth::user();
+ $user = $this->currentUser();
$year = now()->year;
- $driver = \Illuminate\Support\Facades\DB::connection()->getDriverName();
- $yearSql = $driver === 'pgsql' ? "CAST(EXTRACT(YEAR FROM %s) AS INTEGER)" : "strftime('%%Y', %s)";
+ $driver = DB::connection()->getDriverName();
+ $yearSql = $driver === 'pgsql' ? 'CAST(EXTRACT(YEAR FROM %s) AS INTEGER)' : "strftime('%%Y', %s)";
$stats = $user->anime()
- ->selectRaw("COUNT(*) as total")
- ->selectRaw("SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as currently_watching", [WatchingStatus::Watching->value])
- ->selectRaw("SUM(CASE WHEN status = ? AND " . sprintf($yearSql, 'date_finished') . " = ? THEN 1 ELSE 0 END) as watched_this_year", [WatchingStatus::Watched->value, (string) $year])
+ ->selectRaw('COUNT(*) as total')
+ ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as currently_watching', [WatchingStatus::Watching->value])
+ ->selectRaw('SUM(CASE WHEN status = ? AND '.sprintf($yearSql, 'date_finished').' = ? THEN 1 ELSE 0 END) as watched_this_year', [WatchingStatus::Watched->value, (string) $year])
->first();
return [
- 'currently_watching' => (int) $stats->currently_watching,
- 'watched_this_year' => (int) $stats->watched_this_year,
- 'total_anime' => (int) $stats->total,
+ 'currently_watching' => $this->intAttr($stats, 'currently_watching'),
+ 'watched_this_year' => $this->intAttr($stats, 'watched_this_year'),
+ 'total_anime' => $this->intAttr($stats, 'total'),
];
}
- public function render()
+ /**
+ * @return array
+ */
+ public function getPlayingStats(): array
+ {
+ $user = $this->currentUser();
+
+ $stats = $user->games()
+ ->selectRaw('COUNT(*) as total')
+ ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as currently_playing', [PlayingStatus::Playing->value])
+ ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as backlog', [PlayingStatus::Backlog->value])
+ ->first();
+
+ return [
+ 'total_games' => $this->intAttr($stats, 'total'),
+ 'currently_playing' => $this->intAttr($stats, 'currently_playing'),
+ 'backlog' => $this->intAttr($stats, 'backlog'),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function getListeningStats(): array
+ {
+ $user = $this->currentUser();
+
+ $concertStats = $user->concerts()
+ ->selectRaw('COUNT(*) as total')
+ ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as attended', [ListeningStatus::Attended->value])
+ ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as upcoming', [ListeningStatus::Going->value])
+ ->first();
+
+ $albumStats = $user->albums()
+ ->selectRaw('COUNT(*) as total')
+ ->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as listening', [CollectionStatus::Listening->value])
+ ->first();
+
+ return [
+ 'total_concerts' => $this->intAttr($concertStats, 'total'),
+ 'attended' => $this->intAttr($concertStats, 'attended'),
+ 'upcoming' => $this->intAttr($concertStats, 'upcoming'),
+ 'total_albums' => $this->intAttr($albumStats, 'total'),
+ 'currently_listening' => $this->intAttr($albumStats, 'listening'),
+ ];
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.dashboard', [
'categories' => $this->getCategories(),
'readingStats' => $this->getReadingStats(),
'watchingStats' => $this->getWatchingStats(),
'animeStats' => $this->getAnimeStats(),
- ])->layout('layouts.app');
+ 'playingStats' => $this->getPlayingStats(),
+ 'listeningStats' => $this->getListeningStats(),
+ ]);
+ }
+
+ private function currentUser(): User
+ {
+ $user = Auth::user();
+
+ if (! $user instanceof User) {
+ abort(403);
+ }
+
+ return $user;
+ }
+
+ private function intAttr(?Model $model, string $key): int
+ {
+ $value = $model?->getAttribute($key);
+
+ return is_numeric($value) ? (int) $value : 0;
}
}
diff --git a/app/Livewire/Forms/LoginForm.php b/app/Livewire/Forms/LoginForm.php
index 0239fba..2752db6 100644
--- a/app/Livewire/Forms/LoginForm.php
+++ b/app/Livewire/Forms/LoginForm.php
@@ -1,5 +1,7 @@
ensureIsNotRateLimited();
- if (! Auth::attempt($this->only(['name', 'password']), $this->remember)) {
+ $credentials = ['name' => $this->name, 'password' => $this->password];
+
+ if (! Auth::attempt($credentials, $this->remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
diff --git a/app/Livewire/Games/GameForm.php b/app/Livewire/Games/GameForm.php
new file mode 100644
index 0000000..983ae86
--- /dev/null
+++ b/app/Livewire/Games/GameForm.php
@@ -0,0 +1,238 @@
+ */
+ public array $platform = [];
+
+ /** @var array */
+ public array $genre = [];
+
+ public string $genreInput = '';
+
+ public string $description = '';
+
+ public string $cover_url = '';
+
+ public ?string $release_date = null;
+
+ public string $developer = '';
+
+ public string $publisher = '';
+
+ public string $status = 'backlog';
+
+ public string $ownership = 'not_owned';
+
+ public ?int $rating = null;
+
+ public ?float $hours_played = null;
+
+ public ?int $completion_percentage = null;
+
+ public ?int $igdb_id = null;
+
+ public ?int $rawg_id = null;
+
+ public ?int $mobygames_id = null;
+
+ public ?string $date_started = null;
+
+ public ?string $date_finished = null;
+
+ public string $notes = '';
+
+ public string $platformInput = '';
+
+ public function mount(?Game $game = null): void
+ {
+ if ($game && $game->exists) {
+ $this->authorize('update', $game);
+ $this->game = $game;
+ $this->fill([
+ 'title' => $game->title,
+ 'platform' => $game->platform ?? [],
+ 'genre' => $game->genre ?? [],
+ 'description' => $game->description ?? '',
+ 'cover_url' => $game->cover_url ?? '',
+ 'release_date' => $game->release_date?->format('d/m/Y'),
+ 'developer' => $game->developer ?? '',
+ 'publisher' => $game->publisher ?? '',
+ 'status' => $game->status->value,
+ 'ownership' => $game->ownership->value,
+ 'rating' => $game->rating,
+ 'hours_played' => $game->hours_played ? (float) $game->hours_played : null,
+ 'completion_percentage' => $game->completion_percentage,
+ 'igdb_id' => $game->igdb_id,
+ 'rawg_id' => $game->rawg_id,
+ 'mobygames_id' => $game->mobygames_id,
+ 'date_started' => $game->date_started?->format('d/m/Y'),
+ 'date_finished' => $game->date_finished?->format('d/m/Y'),
+ 'notes' => $game->notes ?? '',
+ ]);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function rules(): array
+ {
+ return [
+ 'title' => ['required', 'string', 'max:255'],
+ 'platform' => ['nullable', 'array'],
+ 'platform.*' => ['string', 'max:50'],
+ 'genre' => ['nullable', 'array'],
+ 'genre.*' => ['string', 'max:100'],
+ 'description' => ['nullable', 'string', 'max:10000'],
+ 'cover_url' => ['nullable', 'url', 'max:2048'],
+ 'release_date' => ['nullable', 'date_format:d/m/Y'],
+ 'developer' => ['nullable', 'string', 'max:255'],
+ 'publisher' => ['nullable', 'string', 'max:255'],
+ 'status' => ['required', Rule::enum(PlayingStatus::class)],
+ 'ownership' => ['required', Rule::enum(OwnershipStatus::class)],
+ 'rating' => ['nullable', 'integer', 'min:1', 'max:10'],
+ 'hours_played' => ['nullable', 'numeric', 'min:0', 'max:99999'],
+ 'completion_percentage' => ['nullable', 'integer', 'min:0', 'max:100'],
+ 'igdb_id' => ['nullable', 'integer'],
+ 'rawg_id' => ['nullable', 'integer'],
+ 'mobygames_id' => ['nullable', 'integer'],
+ 'date_started' => ['nullable', 'date_format:d/m/Y'],
+ 'date_finished' => ['nullable', 'date_format:d/m/Y'],
+ 'notes' => ['nullable', 'string', 'max:10000'],
+ ];
+ }
+
+ protected function parseDateInput(mixed $date): ?string
+ {
+ if (! is_string($date) || $date === '') {
+ return null;
+ }
+
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
+ return $date;
+ }
+
+ if (preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $date, $matches)) {
+ $day = $matches[1];
+ $month = $matches[2];
+ $year = $matches[3];
+
+ if (checkdate((int) $month, (int) $day, (int) $year)) {
+ return "{$year}-{$month}-{$day}";
+ }
+ }
+
+ return null;
+ }
+
+ public function addGenre(): void
+ {
+ $genre = trim($this->genreInput);
+ if ($genre !== '' && ! in_array($genre, $this->genre)) {
+ $this->genre[] = $genre;
+ }
+ $this->genreInput = '';
+ }
+
+ public function removeGenre(int $index): void
+ {
+ unset($this->genre[$index]);
+ $this->genre = array_values($this->genre);
+ }
+
+ public function addPlatform(): void
+ {
+ $platform = trim($this->platformInput);
+ if ($platform !== '' && ! in_array($platform, $this->platform)) {
+ $this->platform[] = $platform;
+ }
+ $this->platformInput = '';
+ }
+
+ public function removePlatform(int $index): void
+ {
+ unset($this->platform[$index]);
+ $this->platform = array_values($this->platform);
+ }
+
+ public function save(): void
+ {
+ $validated = $this->validate();
+ $validated = is_array($validated) ? $validated : [];
+
+ $validated['release_date'] = $this->parseDateInput($validated['release_date'] ?? null);
+ $validated['date_started'] = $this->parseDateInput($validated['date_started'] ?? null);
+ $validated['date_finished'] = $this->parseDateInput($validated['date_finished'] ?? null);
+
+ $data = [
+ 'title' => $validated['title'],
+ 'platform' => empty($validated['platform']) ? null : $validated['platform'],
+ 'genre' => empty($validated['genre']) ? null : $validated['genre'],
+ 'description' => $validated['description'] ?: null,
+ 'cover_url' => $validated['cover_url'] ?: null,
+ 'release_date' => $validated['release_date'],
+ 'developer' => $validated['developer'] ?: null,
+ 'publisher' => $validated['publisher'] ?: null,
+ 'status' => $validated['status'],
+ 'ownership' => $validated['ownership'],
+ 'rating' => $validated['rating'],
+ 'hours_played' => $validated['hours_played'],
+ 'completion_percentage' => $validated['completion_percentage'],
+ 'igdb_id' => $validated['igdb_id'],
+ 'rawg_id' => $validated['rawg_id'],
+ 'mobygames_id' => $validated['mobygames_id'],
+ 'date_started' => $validated['date_started'],
+ 'date_finished' => $validated['date_finished'],
+ 'notes' => $validated['notes'] ?: null,
+ ];
+
+ if ($this->game) {
+ $this->game->update($data);
+ $message = 'Game updated successfully.';
+ } else {
+ $data['user_id'] = Auth::id();
+ $this->game = Game::create($data);
+ $message = 'Game created successfully.';
+ }
+
+ session()->flash('message', $message);
+
+ $this->redirect(route('games.show', $this->game));
+ }
+
+ public function isEditing(): bool
+ {
+ return $this->game instanceof Game && $this->game->exists;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.games.game-form', [
+ 'statuses' => PlayingStatus::cases(),
+ 'ownershipStatuses' => OwnershipStatus::cases(),
+ 'isEditing' => $this->isEditing(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Games/GameIgdbSearch.php b/app/Livewire/Games/GameIgdbSearch.php
new file mode 100644
index 0000000..7a28061
--- /dev/null
+++ b/app/Livewire/Games/GameIgdbSearch.php
@@ -0,0 +1,270 @@
+> */
+ public array $searchResults = [];
+
+ public int $currentPage = 1;
+
+ public bool $hasMorePages = false;
+
+ public const PLATFORM_OPTIONS = [
+ '' => 'All Platforms',
+ '6' => 'PC',
+ '14' => 'Mac',
+ '3' => 'Linux',
+ '167' => 'PS5',
+ '48' => 'PS4',
+ '9' => 'PS3',
+ '8' => 'PS2',
+ '7' => 'PS1',
+ '169' => 'Xbox Series X|S',
+ '49' => 'Xbox One',
+ '12' => 'Xbox 360',
+ '11' => 'Xbox',
+ '130' => 'Nintendo Switch',
+ '41' => 'Wii U',
+ '5' => 'Wii',
+ '21' => 'GameCube',
+ '4' => 'N64',
+ '19' => 'SNES',
+ '18' => 'NES',
+ '37' => '3DS',
+ '20' => 'Nintendo DS',
+ '24' => 'GBA',
+ '33' => 'Game Boy',
+ '29' => 'Sega Genesis',
+ '32' => 'Sega Saturn',
+ '23' => 'Dreamcast',
+ '38' => 'PSP',
+ '46' => 'PS Vita',
+ '34' => 'Android',
+ '39' => 'iOS',
+ '170' => 'Google Stadia',
+ ];
+
+ // Selected game configuration
+ public string $title = '';
+
+ public ?string $summary = null;
+
+ public string $cover_url = '';
+
+ public ?string $developer = null;
+
+ public ?string $publisher = null;
+
+ /** @var array */
+ public array $availablePlatforms = [];
+
+ /** @var array */
+ public array $selectedPlatforms = [];
+
+ public string $customPlatformInput = '';
+
+ /** @var array */
+ public array $genre = [];
+
+ public ?string $release_date = null;
+
+ public string $status = 'want_to_play';
+
+ public string $ownership = 'not_owned';
+
+ public ?int $rating = null;
+
+ public ?int $igdb_id = null;
+
+ // Duplicate detection
+ /** @var array */
+ public array $existingIgdbIds = [];
+
+ public function mount(): void
+ {
+ $this->existingIgdbIds = Game::where('user_id', Auth::id())
+ ->whereNotNull('igdb_id')
+ ->pluck('igdb_id')
+ ->all();
+ }
+
+ public function search(): void
+ {
+ $query = trim($this->query);
+ if ($query === '') {
+ return;
+ }
+
+ $platformId = $this->platformFilter !== '' ? (int) $this->platformFilter : null;
+ $result = app(IgdbService::class)->search($query, 1, 20, $platformId);
+
+ $this->searchResults = $result['results'];
+ $this->hasMorePages = count($result['results']) >= 20;
+ $this->currentPage = 1;
+ $this->step = 'results';
+ }
+
+ public function loadPage(int $page): void
+ {
+ $platformId = $this->platformFilter !== '' ? (int) $this->platformFilter : null;
+ $result = app(IgdbService::class)->search(trim($this->query), $page, 20, $platformId);
+
+ $this->searchResults = $result['results'];
+ $this->hasMorePages = count($result['results']) >= 20;
+ $this->currentPage = $page;
+ }
+
+ public function selectResult(int $index): void
+ {
+ $result = $this->searchResults[$index] ?? null;
+ if (! $result) {
+ return;
+ }
+
+ $this->igdb_id = $this->intOrNull($result['igdb_id'] ?? null);
+ $this->title = $this->strOf($result['title'] ?? null);
+ $this->summary = $this->strOrNull($result['summary'] ?? null);
+ $this->cover_url = $this->strOf($result['cover_url'] ?? null);
+ $this->developer = $this->strOrNull($result['developer'] ?? null);
+ $this->publisher = $this->strOrNull($result['publisher'] ?? null);
+ $this->availablePlatforms = is_array($result['platforms'] ?? null) ? $result['platforms'] : [];
+ $this->genre = is_array($result['genres'] ?? null) ? $result['genres'] : [];
+ $this->release_date = $this->strOrNull($result['release_date'] ?? null);
+ $this->status = 'backlog';
+ $this->ownership = 'not_owned';
+ $this->rating = null;
+ $this->customPlatformInput = '';
+
+ // Pre-select the filtered platform if one was used
+ $this->selectedPlatforms = [];
+ if ($this->platformFilter !== '') {
+ $filterLabel = self::PLATFORM_OPTIONS[$this->platformFilter] ?? null;
+ if ($filterLabel) {
+ foreach ($this->availablePlatforms as $plat) {
+ if (! is_string($plat)) {
+ continue;
+ }
+ if ($plat === $filterLabel || str_contains($plat, $filterLabel)) {
+ $this->selectedPlatforms = [$plat];
+ break;
+ }
+ }
+ }
+ }
+
+ $this->step = 'configure';
+ }
+
+ private function strOf(mixed $value): string
+ {
+ return is_string($value) ? $value : '';
+ }
+
+ private function strOrNull(mixed $value): ?string
+ {
+ return is_string($value) && $value !== '' ? $value : null;
+ }
+
+ private function intOrNull(mixed $value): ?int
+ {
+ return is_numeric($value) ? (int) $value : null;
+ }
+
+ public function togglePlatform(string $platform): void
+ {
+ if (in_array($platform, $this->selectedPlatforms)) {
+ $this->selectedPlatforms = array_values(array_diff($this->selectedPlatforms, [$platform]));
+ } else {
+ $this->selectedPlatforms[] = $platform;
+ }
+ }
+
+ public function addCustomPlatform(): void
+ {
+ $platform = trim($this->customPlatformInput);
+ if ($platform !== '' && ! in_array($platform, $this->selectedPlatforms)) {
+ $this->selectedPlatforms[] = $platform;
+ }
+ $this->customPlatformInput = '';
+ }
+
+ public function removeSelectedPlatform(int $index): void
+ {
+ unset($this->selectedPlatforms[$index]);
+ $this->selectedPlatforms = array_values($this->selectedPlatforms);
+ }
+
+ public function addGame(): void
+ {
+ $game = Game::create([
+ 'user_id' => Auth::id(),
+ 'title' => $this->title,
+ 'description' => $this->summary ?: null,
+ 'cover_url' => $this->cover_url ?: null,
+ 'developer' => $this->developer ?: null,
+ 'publisher' => $this->publisher ?: null,
+ 'platform' => $this->selectedPlatforms === [] ? null : $this->selectedPlatforms,
+ 'genre' => $this->genre === [] ? null : $this->genre,
+ 'release_date' => $this->release_date,
+ 'status' => $this->status,
+ 'ownership' => $this->ownership,
+ 'rating' => $this->rating,
+ 'igdb_id' => $this->igdb_id,
+ ]);
+
+ session()->flash('message', "Added \"{$this->title}\" to your library.");
+ $this->redirect(route('games.show', $game));
+ }
+
+ public function backToSearch(): void
+ {
+ $this->step = 'search';
+ $this->searchResults = [];
+ $this->query = '';
+ }
+
+ public function backToResults(): void
+ {
+ $this->step = 'results';
+ }
+
+ /**
+ * @param array $result
+ */
+ public function isResultDuplicate(array $result): bool
+ {
+ $igdbId = $result['igdb_id'] ?? null;
+
+ return $igdbId && in_array($igdbId, $this->existingIgdbIds);
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.games.game-igdb-search', [
+ 'statuses' => PlayingStatus::cases(),
+ 'ownershipStatuses' => OwnershipStatus::cases(),
+ 'platformOptions' => self::PLATFORM_OPTIONS,
+ ]);
+ }
+}
diff --git a/app/Livewire/Games/GameIndex.php b/app/Livewire/Games/GameIndex.php
new file mode 100644
index 0000000..e49867b
--- /dev/null
+++ b/app/Livewire/Games/GameIndex.php
@@ -0,0 +1,192 @@
+ */
+ public array $selected = [];
+
+ public bool $selectAll = false;
+
+ private const ALLOWED_SORT_COLUMNS = ['title', 'rating', 'release_date', 'hours_played', 'completion_percentage', 'date_started', 'date_finished', 'updated_at'];
+
+ /** @var array */
+ protected $queryString = [
+ 'search' => ['except' => ''],
+ 'status' => ['except' => ''],
+ 'ownership' => ['except' => ''],
+ 'platform' => ['except' => ''],
+ 'genre' => ['except' => ''],
+ 'sortBy' => ['except' => 'updated_at'],
+ 'sortDirection' => ['except' => 'desc'],
+ 'viewMode' => ['except' => 'gallery'],
+ ];
+
+ public function updatingSearch(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatingStatus(string $value): void
+ {
+ if ($value === 'backlog' && in_array($this->sortBy, ['date_finished', 'date_started'])) {
+ $this->sortBy = 'updated_at';
+ } elseif (in_array($value, ['playing', 'shelved']) && $this->sortBy === 'date_finished') {
+ $this->sortBy = 'date_started';
+ }
+
+ $this->resetPage();
+ }
+
+ public function updatingOwnership(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatingPlatform(): void
+ {
+ $this->resetPage();
+ }
+
+ public function updatingGenre(): void
+ {
+ $this->resetPage();
+ }
+
+ public function deleteGame(Game $game): void
+ {
+ $this->authorize('delete', $game);
+
+ $game->delete();
+
+ session()->flash('message', 'Game deleted successfully.');
+ }
+
+ public function updatedSelectAll(bool $value): void
+ {
+ if ($value) {
+ $query = $this->buildQuery();
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
+ } else {
+ $this->selected = [];
+ }
+ }
+
+ public function deleteSelected(): void
+ {
+ $deleted = Game::query()
+ ->where('user_id', Auth::id())
+ ->whereIn('id', $this->selected)
+ ->delete();
+ $count = is_int($deleted) ? $deleted : 0;
+
+ $this->selected = [];
+ $this->selectAll = false;
+
+ session()->flash('message', "{$count} game(s) deleted successfully.");
+ }
+
+ /**
+ * @return Builder
+ */
+ protected function buildQuery(): Builder
+ {
+ $query = Game::query()
+ ->where('user_id', Auth::id())
+ ->when($this->status, function ($query): void {
+ $query->where('status', $this->status);
+ })
+ ->when($this->ownership, function ($query): void {
+ $query->where('ownership', $this->ownership);
+ })
+ ->when($this->platform, function ($query): void {
+ $query->whereJsonContains('platform', $this->platform);
+ })
+ ->when($this->genre, function ($query): void {
+ $query->whereJsonContains('genre', $this->genre);
+ });
+
+ if ($this->search) {
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'developer', 'publisher']);
+ }
+
+ return $query;
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ $perPage = $this->viewMode === 'list' ? 25 : 18;
+ $sortBy = $this->safeSortBy();
+ $sortDir = $this->safeSortDirection();
+
+ $query = $this->buildQuery();
+ if (in_array($sortBy, ['rating', 'hours_played', 'completion_percentage', 'date_started', 'date_finished', 'release_date'])) {
+ $query->orderByRaw("\"$sortBy\" $sortDir NULLS LAST");
+ } else {
+ $query->orderBy($sortBy, $sortDir);
+ }
+ $query->orderBy('id');
+
+ $games = $query->paginate($perPage);
+
+ $allPlatforms = Game::where('user_id', Auth::id())
+ ->whereNotNull('platform')
+ ->pluck('platform')
+ ->flatten()
+ ->unique()
+ ->sort()
+ ->values();
+
+ $allGenres = Game::where('user_id', Auth::id())
+ ->whereNotNull('genre')
+ ->pluck('genre')
+ ->flatten()
+ ->unique()
+ ->sort()
+ ->values();
+
+ return view('livewire.games.game-index', [
+ 'games' => $games,
+ 'statuses' => PlayingStatus::cases(),
+ 'ownershipStatuses' => OwnershipStatus::cases(),
+ 'allPlatforms' => $allPlatforms,
+ 'allGenres' => $allGenres,
+ ]);
+ }
+}
diff --git a/app/Livewire/Games/GameShow.php b/app/Livewire/Games/GameShow.php
new file mode 100644
index 0000000..0195899
--- /dev/null
+++ b/app/Livewire/Games/GameShow.php
@@ -0,0 +1,136 @@
+authorize('view', $game);
+ $this->game = $game;
+ }
+
+ public function updateRating(int $rating): void
+ {
+ $this->authorize('update', $this->game);
+
+ $newRating = $this->game->rating === $rating ? null : $rating;
+ $this->game->update(['rating' => $newRating]);
+ }
+
+ public function deleteGame(): void
+ {
+ $this->authorize('delete', $this->game);
+
+ $this->game->delete();
+
+ session()->flash('message', 'Game deleted successfully.');
+ $this->redirect(route('games.index'));
+ }
+
+ /**
+ * @return array
+ */
+ public static function platformMeta(string $platform): array
+ {
+ $lower = strtolower($platform);
+
+ if (str_contains($lower, 'game boy color') || str_contains($lower, 'gbc')) {
+ return ['key' => 'nintendo', 'logo' => 'gameboy_color.svg'];
+ }
+
+ if (str_contains($lower, 'game boy advance') || str_contains($lower, 'gba')) {
+ return ['key' => 'nintendo', 'logo' => 'gameboy.svg'];
+ }
+
+ if (str_contains($lower, 'game boy') || str_contains($lower, 'gameboy')) {
+ return ['key' => 'nintendo', 'logo' => 'gameboy.svg'];
+ }
+
+ if (str_contains($lower, 'switch')) {
+ return ['key' => 'nintendo', 'logo' => 'nintendo_switch.svg'];
+ }
+
+ if (str_contains($lower, 'nintendo entertainment system') || $lower === 'nes') {
+ return ['key' => 'nintendo', 'logo' => 'nes.svg'];
+ }
+
+ if (str_contains($lower, 'nintendo 64') || $lower === 'n64') {
+ return ['key' => 'nintendo', 'logo' => 'n64.svg'];
+ }
+
+ if (str_contains($lower, 'super nintendo') || str_contains($lower, 'snes')) {
+ return ['key' => 'nintendo', 'logo' => 'nintendo_switch.svg'];
+ }
+
+ if (str_contains($lower, 'nintendo') || str_contains($lower, 'wii') || str_contains($lower, 'gamecube') || str_contains($lower, '3ds')) {
+ return ['key' => 'nintendo', 'logo' => 'nintendo_switch.svg'];
+ }
+
+ if (str_contains($lower, 'steam')) {
+ return ['key' => 'steam', 'logo' => 'steam.svg'];
+ }
+
+ if (str_contains($lower, 'gog')) {
+ return ['key' => 'gog', 'logo' => 'gog.svg'];
+ }
+
+ if (str_contains($lower, 'playstation 2') || str_contains($lower, 'ps2')) {
+ return ['key' => 'playstation', 'logo' => 'ps2.svg'];
+ }
+
+ if (str_contains($lower, 'playstation') || str_contains($lower, 'ps vita') || str_contains($lower, 'psp')) {
+ $logo = 'playstation4.svg';
+ if (str_contains($lower, '5')) {
+ $logo = 'playstation5.svg';
+ }
+
+ return ['key' => 'playstation', 'logo' => $logo];
+ }
+
+ if (str_contains($lower, 'xbox')) {
+ $logo = str_contains($lower, 'series') ? 'xbox_series.svg' : 'xbox_one.svg';
+
+ return ['key' => 'xbox', 'logo' => $logo];
+ }
+
+ if (str_contains($lower, 'pc') || str_contains($lower, 'windows') || str_contains($lower, 'linux') || str_contains($lower, 'mac')) {
+ return ['key' => 'pc', 'logo' => 'windows.svg'];
+ }
+
+ return ['key' => 'default', 'logo' => null];
+ }
+
+ public static function shortPlatformName(string $platform): string
+ {
+ return match ($platform) {
+ 'Nintendo Entertainment System' => 'NES',
+ 'Super Nintendo Entertainment System' => 'SNES',
+ 'Nintendo 64' => 'N64',
+ 'PlayStation 2' => 'PS2',
+ 'Game Boy Advance' => 'GBA',
+ 'Game Boy Color' => 'GBC',
+ 'Game Boy' => 'GB',
+ 'PC (Steam)' => 'Steam',
+ default => $platform,
+ };
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.games.game-show');
+ }
+}
diff --git a/app/Livewire/Listening/ListeningIndex.php b/app/Livewire/Listening/ListeningIndex.php
new file mode 100644
index 0000000..be266e3
--- /dev/null
+++ b/app/Livewire/Listening/ListeningIndex.php
@@ -0,0 +1,45 @@
+>
+ */
+ public function getSubcategories(): array
+ {
+ return [
+ [
+ 'name' => 'Live',
+ 'icon' => 'ticket',
+ 'description' => 'Concerts, gigs, and live events',
+ 'route' => 'concerts.index',
+ 'active' => true,
+ 'color' => 'orange',
+ ],
+ [
+ 'name' => 'Collection',
+ 'icon' => 'disc',
+ 'description' => 'Albums, records, and music you own or want',
+ 'route' => 'albums.index',
+ 'active' => true,
+ 'color' => 'purple',
+ ],
+ ];
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.listening.listening-index', [
+ 'subcategories' => $this->getSubcategories(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Movies/MovieForm.php b/app/Livewire/Movies/MovieForm.php
index a7d8286..1677487 100644
--- a/app/Livewire/Movies/MovieForm.php
+++ b/app/Livewire/Movies/MovieForm.php
@@ -6,9 +6,11 @@
use App\Enums\WatchingStatus;
use App\Models\Movie;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class MovieForm extends Component
@@ -69,6 +71,9 @@ public function mount(?Movie $movie = null): void
}
}
+ /**
+ * @return array
+ */
public function rules(): array
{
return [
@@ -89,9 +94,9 @@ public function rules(): array
];
}
- protected function parseDateInput(?string $date): ?string
+ protected function parseDateInput(mixed $date): ?string
{
- if (empty($date)) {
+ if (! is_string($date) || $date === '') {
return null;
}
@@ -115,6 +120,7 @@ protected function parseDateInput(?string $date): ?string
public function save(): void
{
$validated = $this->validate();
+ $validated = is_array($validated) ? $validated : [];
$validated['release_date'] = $this->parseDateInput($validated['release_date'] ?? null);
$validated['date_watched'] = $this->parseDateInput($validated['date_watched'] ?? null);
@@ -151,6 +157,9 @@ public function save(): void
$this->redirect(route('movies.show', $this->movie));
}
+ /**
+ * @return list
+ */
public function getStatuses(): array
{
return WatchingStatus::cases();
@@ -158,14 +167,15 @@ public function getStatuses(): array
public function isEditing(): bool
{
- return $this->movie !== null && $this->movie->exists;
+ return $this->movie instanceof Movie && $this->movie->exists;
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.movies.movie-form', [
'statuses' => $this->getStatuses(),
'isEditing' => $this->isEditing(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Movies/MovieImport.php b/app/Livewire/Movies/MovieImport.php
index d444cef..5245730 100644
--- a/app/Livewire/Movies/MovieImport.php
+++ b/app/Livewire/Movies/MovieImport.php
@@ -4,26 +4,37 @@
namespace App\Livewire\Movies;
+use App\Models\User;
use App\Services\ImdbImportService;
+use Exception;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
+use InvalidArgumentException;
use Livewire\Component;
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
+use RuntimeException;
class MovieImport extends Component
{
use WithFileUploads;
- public $file;
+ public ?TemporaryUploadedFile $file = null;
public bool $skipDuplicates = true;
+ /** @var Collection|null */
public ?Collection $preview = null;
+ /** @var array|null */
public ?array $importResult = null;
public bool $importing = false;
+ /**
+ * @return array
+ */
protected function rules(): array
{
return [
@@ -39,26 +50,34 @@ public function updatedFile(): void
protected function generatePreview(): void
{
- $content = file_get_contents($this->file->getRealPath());
+ $content = $this->uploadedContent();
+
+ if ($content === null) {
+ return;
+ }
try {
- $service = new ImdbImportService;
- $categorized = $service->parseCSV($content);
+ $categorized = (new ImdbImportService)->parseCSV($content);
// Take first 5 of each type for preview
+ $movies = $categorized->get('movies') ?? collect();
+ $shows = $categorized->get('shows') ?? collect();
+ $episodes = $categorized->get('episodes') ?? collect();
+
+ /** @var Collection $preview */
$preview = collect();
- if ($categorized['movies']->count() > 0) {
- $preview['movies'] = $categorized['movies']->take(5);
+ if ($movies->count() > 0) {
+ $preview['movies'] = $movies->take(5);
}
- if ($categorized['shows']->count() > 0) {
- $preview['shows'] = $categorized['shows']->take(5);
+ if ($shows->count() > 0) {
+ $preview['shows'] = $shows->take(5);
}
- if ($categorized['episodes']->count() > 0) {
- $preview['episodes'] = $categorized['episodes']->take(5);
+ if ($episodes->count() > 0) {
+ $preview['episodes'] = $episodes->take(5);
}
$this->preview = $preview->count() > 0 ? $preview : null;
- } catch (\InvalidArgumentException $e) {
+ } catch (InvalidArgumentException $e) {
$this->addError('file', $e->getMessage());
$this->file = null;
$this->preview = null;
@@ -71,17 +90,20 @@ public function import(): void
$this->importing = true;
try {
- $content = file_get_contents($this->file->getRealPath());
+ $content = $this->uploadedContent();
+
+ if ($content === null) {
+ throw new RuntimeException('Could not read the uploaded file.');
+ }
$service = new ImdbImportService;
- $categorized = $service->parseCSV($content);
$this->importResult = $service->importAll(
- Auth::user(),
- $categorized,
+ $this->currentUser(),
+ $service->parseCSV($content),
$this->skipDuplicates
);
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->importResult = [
'imported' => 0,
'skipped' => 0,
@@ -102,7 +124,31 @@ public function resetForm(): void
$this->importResult = null;
}
- public function render()
+ private function uploadedContent(): ?string
+ {
+ $path = $this->file?->getRealPath();
+
+ if (! is_string($path) || $path === '') {
+ return null;
+ }
+
+ $content = file_get_contents($path);
+
+ return $content === false ? null : $content;
+ }
+
+ private function currentUser(): User
+ {
+ $user = Auth::user();
+
+ if (! $user instanceof User) {
+ abort(403);
+ }
+
+ return $user;
+ }
+
+ public function render(): View
{
return view('livewire.movies.movie-import');
}
diff --git a/app/Livewire/Movies/MovieIndex.php b/app/Livewire/Movies/MovieIndex.php
index 6e6f60a..ad06698 100644
--- a/app/Livewire/Movies/MovieIndex.php
+++ b/app/Livewire/Movies/MovieIndex.php
@@ -5,15 +5,20 @@
namespace App\Livewire\Movies;
use App\Enums\WatchingStatus;
+use App\Livewire\Concerns\WithAccentInsensitiveSearch;
+use App\Livewire\Concerns\WithIndexFiltering;
use App\Models\Movie;
+use Illuminate\Contracts\View\View;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Str;
+use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
class MovieIndex extends Component
{
+ use WithAccentInsensitiveSearch;
+ use WithIndexFiltering;
use WithPagination;
private const TV_SHOW_TYPES = ['TV Episode', 'TV Series', 'TV Mini Series'];
@@ -34,12 +39,14 @@ class MovieIndex extends Component
public string $viewMode = 'gallery';
+ /** @var array */
public array $selected = [];
public bool $selectAll = false;
private const ALLOWED_SORT_COLUMNS = ['title', 'rating', 'runtime_minutes', 'year', 'date_watched', 'updated_at', 'imdb_rating', 'release_date'];
+ /** @var array */
protected $queryString = [
'search' => ['except' => ''],
'status' => ['except' => ''],
@@ -51,30 +58,17 @@ class MovieIndex extends Component
'viewMode' => ['except' => 'gallery'],
];
- private function normalizeForSearch(string $string): string
- {
- return Str::ascii($string);
- }
-
- private function matchesSearch(?string $value, string $normalizedSearch): bool
- {
- if ($value === null) {
- return false;
- }
-
- return str_contains(
- strtolower($this->normalizeForSearch($value)),
- strtolower($normalizedSearch)
- );
- }
-
public function updatingSearch(): void
{
$this->resetPage();
}
- public function updatingStatus(): void
+ public function updatingStatus(string $value): void
{
+ if ($value !== '' && $value !== 'watched' && $this->sortBy === 'date_watched') {
+ $this->sortBy = 'updated_at';
+ }
+
$this->resetPage();
}
@@ -94,31 +88,6 @@ public function toggleHideEpisodes(): void
$this->resetPage();
}
- public function setViewMode(string $mode): void
- {
- $this->viewMode = in_array($mode, ['gallery', 'list']) ? $mode : 'gallery';
- }
-
- public function sort(string $column): void
- {
- if ($this->sortBy === $column) {
- $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
- } else {
- $this->sortBy = $column;
- $this->sortDirection = 'asc';
- }
- }
-
- private function safeSortDirection(): string
- {
- return $this->sortDirection === 'asc' ? 'asc' : 'desc';
- }
-
- private function safeSortBy(): string
- {
- return in_array($this->sortBy, self::ALLOWED_SORT_COLUMNS, true) ? $this->sortBy : 'updated_at';
- }
-
public function deleteMovie(Movie $movie): void
{
$this->authorize('delete', $movie);
@@ -133,25 +102,20 @@ public function updatedSelectAll(bool $value): void
if ($value) {
$query = Movie::query()
->where('user_id', Auth::id())
- ->when($this->status, function ($query) {
+ ->when($this->status, function ($query): void {
$query->where('status', $this->status);
})
- ->when($this->genre, function ($query) {
- $query->where('genres', 'like', '%' . $this->genre . '%');
+ ->when($this->genre, function ($query): void {
+ $query->where('genres', 'like', '%'.$this->genre.'%');
});
$this->applyTypeFilter($query);
if ($this->search) {
- $normalizedSearch = $this->normalizeForSearch($this->search);
- $allMovies = $query->get();
- $this->selected = $allMovies->filter(function ($movie) use ($normalizedSearch) {
- return $this->matchesSearch($movie->title, $normalizedSearch)
- || $this->matchesSearch($movie->director, $normalizedSearch)
- || $this->matchesSearch($movie->original_title, $normalizedSearch);
- })->pluck('id')->map(fn ($id) => (string) $id)->toArray();
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'director', 'original_title']);
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
} else {
- $this->selected = $query->pluck('id')->map(fn ($id) => (string) $id)->toArray();
+ $this->selected = $query->pluck('id')->map(fn ($id): string => is_scalar($id) ? (string) $id : '')->values()->all();
}
} else {
$this->selected = [];
@@ -160,10 +124,11 @@ public function updatedSelectAll(bool $value): void
public function deleteSelected(): void
{
- $count = Movie::query()
+ $deleted = Movie::query()
->where('user_id', Auth::id())
->whereIn('id', $this->selected)
->delete();
+ $count = is_int($deleted) ? $deleted : 0;
$this->selected = [];
$this->selectAll = false;
@@ -171,7 +136,10 @@ public function deleteSelected(): void
session()->flash('message', "{$count} movie(s) deleted successfully.");
}
- private function applyTypeFilter($query): void
+ /**
+ * @param Builder $query
+ */
+ private function applyTypeFilter(Builder $query): void
{
if ($this->typeFilter === 'tv_shows') {
$query->whereIn('title_type', self::TV_SHOW_TYPES);
@@ -180,24 +148,23 @@ private function applyTypeFilter($query): void
}
if ($this->hideEpisodes) {
- $query->where(function ($q) {
+ $query->where(function ($q): void {
$q->where('title_type', '!=', 'TV Episode')
->orWhereNull('title_type');
})->whereNull('season_number');
}
}
+ /**
+ * @return array
+ */
public function getStatuses(): array
{
return WatchingStatus::cases();
}
- public function paginationView(): string
- {
- return 'livewire.custom-pagination';
- }
-
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
$perPage = $this->viewMode === 'list' ? 25 : 18;
$sortBy = $this->safeSortBy();
@@ -205,26 +172,17 @@ public function render()
$query = Movie::query()
->where('user_id', Auth::id())
- ->when($this->status, function ($query) {
+ ->when($this->status, function ($query): void {
$query->where('status', $this->status);
})
- ->when($this->genre, function ($query) {
- $query->where('genres', 'like', '%' . $this->genre . '%');
+ ->when($this->genre, function ($query): void {
+ $query->where('genres', 'like', '%'.$this->genre.'%');
});
$this->applyTypeFilter($query);
if ($this->search) {
- $normalizedSearch = $this->normalizeForSearch($this->search);
-
- $allFilteredMovies = (clone $query)->get();
- $matchingIds = $allFilteredMovies->filter(function ($movie) use ($normalizedSearch) {
- return $this->matchesSearch($movie->title, $normalizedSearch)
- || $this->matchesSearch($movie->director, $normalizedSearch)
- || $this->matchesSearch($movie->original_title, $normalizedSearch);
- })->pluck('id');
-
- $query->whereIn('id', $matchingIds);
+ $this->applyAccentInsensitiveSearch($query, $this->search, ['title', 'director', 'original_title']);
}
if ($sortBy === 'runtime_minutes') {
@@ -253,6 +211,8 @@ public function render()
$query->orderBy($sortBy, $sortDir);
}
+ $query->orderBy('id');
+
$movies = $query->paginate($perPage);
$rawTypes = Movie::where('user_id', Auth::id())
@@ -263,13 +223,13 @@ public function render()
// Build curated type list: "TV Shows" replaces the grouped TV types,
// inserted alphabetically among the other types (first in the TV block)
$hasTvShows = $rawTypes->intersect(self::TV_SHOW_TYPES)->isNotEmpty();
- $otherTypes = $rawTypes->reject(fn ($t) => in_array($t, self::TV_SHOW_TYPES))->sort()->values();
+ $otherTypes = $rawTypes->reject(fn ($t): bool => in_array($t, self::TV_SHOW_TYPES))->sort()->values();
$allTypes = collect();
$tvShowsInserted = false;
foreach ($otherTypes as $type) {
// Insert "TV Shows" right before the first item that sorts after it
- if ($hasTvShows && ! $tvShowsInserted && strcasecmp($type, 'TV Shows') > 0) {
+ if ($hasTvShows && ! $tvShowsInserted && strcasecmp(is_string($type) ? $type : '', 'TV Shows') > 0) {
$allTypes->push(['value' => 'tv_shows', 'label' => 'TV Shows']);
$tvShowsInserted = true;
}
@@ -283,8 +243,8 @@ public function render()
return view('livewire.movies.movie-index', [
'movies' => $movies,
'statuses' => $this->getStatuses(),
- 'allGenres' => Movie::getAllGenresForUser(Auth::id()),
+ 'allGenres' => Movie::getAllGenresForUser((int) Auth::id()),
'allTypes' => $allTypes,
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Movies/MovieMetadataEnrichment.php b/app/Livewire/Movies/MovieMetadataEnrichment.php
index a7e547f..7942415 100644
--- a/app/Livewire/Movies/MovieMetadataEnrichment.php
+++ b/app/Livewire/Movies/MovieMetadataEnrichment.php
@@ -5,80 +5,133 @@
namespace App\Livewire\Movies;
use App\Jobs\FetchMovieMetadata;
+use App\Livewire\Concerns\WithMetadataEnrichment;
+use App\Livewire\Concerns\WithSourcePriority;
use App\Models\Movie;
use App\Services\TmdbService;
use App\Services\TraktService;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class MovieMetadataEnrichment extends Component
{
+ use WithMetadataEnrichment;
+ use WithSourcePriority;
+
+ /** @var list */
public array $sourcePriority = ['current', 'trakt', 'tmdb'];
+ /** @var array> */
public array $moviesNeedingEnrichment = [];
public bool $hasScanned = false;
public bool $isScanning = false;
+ /** @var array|null */
public ?array $jobStatus = null;
public bool $showReviewModal = false;
public ?int $reviewingMovieId = null;
+ /** @var array|null */
public ?array $reviewingMovie = null;
+ /** @var array|null */
public ?array $reviewingMetadata = null;
+ /** @var list */
public array $selectedFields = [];
+ /** @var array> */
public array $fetchedData = [];
public int $batchLimit = 100;
public string $activeTab = 'all';
+ /** @var array> */
public array $orphanEpisodes = [];
+ /** @var list */
protected const ENRICHABLE_FIELDS = ['description', 'poster_url', 'runtime_minutes', 'release_date', 'genres', 'director', 'show_name', 'season_number', 'episode_number'];
- public function mount(): void
+ /**
+ * @return array>
+ */
+ protected function enrichmentList(): array
{
- $this->refreshJobStatus();
+ return $this->moviesNeedingEnrichment;
}
- public function refreshJobStatus(): void
+ /**
+ * @param array> $list
+ */
+ protected function setEnrichmentList(array $list): void
{
- $this->jobStatus = FetchMovieMetadata::getStatus(Auth::id());
+ $this->moviesNeedingEnrichment = $list;
}
- public function clearJobStatus(): void
+ protected function setReviewingId(?int $id): void
{
- FetchMovieMetadata::clearStatus(Auth::id());
- $this->jobStatus = null;
+ $this->reviewingMovieId = $id;
}
- public function moveSourceUp(string $source): void
+ /**
+ * @param array|null $item
+ */
+ protected function setReviewingItem(?array $item): void
{
- $index = array_search($source, $this->sourcePriority);
- if ($index > 0) {
- $temp = $this->sourcePriority[$index - 1];
- $this->sourcePriority[$index - 1] = $source;
- $this->sourcePriority[$index] = $temp;
- }
+ $this->reviewingMovie = $item;
}
- public function moveSourceDown(string $source): void
+ /**
+ * @return list
+ */
+ protected function enrichableFields(): array
{
- $index = array_search($source, $this->sourcePriority);
- if ($index < count($this->sourcePriority) - 1) {
- $temp = $this->sourcePriority[$index + 1];
- $this->sourcePriority[$index + 1] = $source;
- $this->sourcePriority[$index] = $temp;
- }
+ return self::ENRICHABLE_FIELDS;
+ }
+
+ /**
+ * @param array|null $data
+ */
+ private function intFrom(?array $data, string $key): int
+ {
+ $value = $data[$key] ?? null;
+
+ return is_numeric($value) ? (int) $value : 0;
+ }
+
+ /**
+ * @param array $data
+ */
+ private function strFrom(array $data, string $key): string
+ {
+ $value = $data[$key] ?? null;
+
+ return is_string($value) ? $value : '';
+ }
+
+ public function mount(): void
+ {
+ $this->refreshJobStatus();
+ }
+
+ public function refreshJobStatus(): void
+ {
+ $this->jobStatus = FetchMovieMetadata::getStatus((int) Auth::id());
+ }
+
+ public function clearJobStatus(): void
+ {
+ FetchMovieMetadata::clearStatus((int) Auth::id());
+ $this->jobStatus = null;
}
public function scanLibrary(): void
@@ -90,7 +143,7 @@ public function scanLibrary(): void
$randomFunction = in_array(DB::getDriverName(), ['sqlite', 'pgsql']) ? 'RANDOM()' : 'RAND()';
$this->moviesNeedingEnrichment = Movie::query()
->where('user_id', Auth::id())
- ->where(function ($query) {
+ ->where(function ($query): void {
$query->whereNull('description')
->orWhere('description', '')
->orWhereNull('poster_url')
@@ -105,7 +158,7 @@ public function scanLibrary(): void
})
->orderByRaw("metadata_fetched_at IS NULL DESC, {$randomFunction}")
->get(['id', 'title', 'title_type', 'director', 'imdb_id', 'year', 'description', 'poster_url', 'runtime_minutes', 'release_date', 'genres', 'season_number', 'episode_number', 'show_name', 'metadata_fetched_at'])
- ->map(function ($movie) {
+ ->map(function (Movie $movie): array {
$missing = $this->getMissingFields($movie);
return [
@@ -130,13 +183,13 @@ public function scanLibrary(): void
'episode_number' => $movie->episode_number,
],
'missing' => $missing,
- 'has_missing' => ! empty($missing),
+ 'has_missing' => $missing !== [],
];
})
- ->filter(fn ($movie) => $movie['has_missing'])
- ->sortByDesc(fn ($movie) => count($movie['missing']))
+ ->filter(fn ($movie): bool => (bool) $movie['has_missing'])
+ ->sortByDesc(fn ($movie): int => count($movie['missing']))
->values()
- ->toArray();
+ ->all();
$this->scanOrphanEpisodes();
@@ -144,6 +197,9 @@ public function scanLibrary(): void
$this->isScanning = false;
}
+ /**
+ * @return list
+ */
protected function getMissingFields(Movie $movie): array
{
$missing = [];
@@ -172,17 +228,17 @@ protected function getMissingFields(Movie $movie): array
public function startBackgroundFetch(): void
{
- if (FetchMovieMetadata::isRunning(Auth::id())) {
+ if (FetchMovieMetadata::isRunning((int) Auth::id())) {
session()->flash('error', 'A metadata fetch is already running.');
return;
}
$moviesToFetch = collect($this->moviesNeedingEnrichment)
- ->filter(fn ($movie) => $movie['has_missing'])
+ ->filter(fn ($movie): bool => (bool) $movie['has_missing'])
->take($this->batchLimit)
->pluck('id')
- ->toArray();
+ ->all();
if (empty($moviesToFetch)) {
session()->flash('message', 'No movies need metadata fetching.');
@@ -199,21 +255,21 @@ public function startBackgroundFetch(): void
'started_at' => now()->toIso8601String(),
'updated_at' => now()->toIso8601String(),
];
- Cache::put('movie_metadata_fetch_' . Auth::id(), $initialStatus, now()->addHours(2));
+ Cache::put('movie_metadata_fetch_'.Auth::id(), $initialStatus, now()->addHours(2));
$this->jobStatus = $initialStatus;
$job = new FetchMovieMetadata(
- Auth::id(),
- $moviesToFetch,
+ (int) Auth::id(),
+ array_values(array_map(fn ($id): int => is_numeric($id) ? (int) $id : 0, $moviesToFetch)),
$this->sourcePriority
);
$job->handle();
$this->refreshJobStatus();
- $fetched = $this->jobStatus['fetched'] ?? 0;
- $applied = $this->jobStatus['applied'] ?? 0;
+ $fetched = $this->intFrom($this->jobStatus, 'fetched');
+ $applied = $this->intFrom($this->jobStatus, 'applied');
session()->flash('message', "Metadata fetch completed! Updated {$applied} of {$fetched} movies.");
}
@@ -221,7 +277,7 @@ public function fetchSingleMovie(int $id): void
{
$movieData = collect($this->moviesNeedingEnrichment)->firstWhere('id', $id);
- if (! $movieData) {
+ if (! is_array($movieData)) {
return;
}
@@ -229,12 +285,14 @@ public function fetchSingleMovie(int $id): void
$trakt = app(TraktService::class);
$metadata = null;
+ $imdbId = $this->strFrom($movieData, 'imdb_id');
+ $title = $this->strFrom($movieData, 'title');
$isEpisode = ! empty($movieData['is_episode']) || ($movieData['title_type'] ?? '') === 'TV Episode';
if ($isEpisode) {
// Episode: fetch show poster + episode details from TMDB
- if (! empty($movieData['imdb_id'])) {
- $episodeDetails = $tmdb->findEpisodeDetailsByImdbId($movieData['imdb_id']);
+ if ($imdbId !== '') {
+ $episodeDetails = $tmdb->findEpisodeDetailsByImdbId($imdbId);
if ($episodeDetails) {
$metadata = $episodeDetails;
}
@@ -242,11 +300,11 @@ public function fetchSingleMovie(int $id): void
// Fallback: search TV shows by show_name or title prefix
if (! $metadata) {
- $showName = $movieData['show_name'] ?? null;
- if (empty($showName) && str_contains($movieData['title'], ':')) {
- $showName = trim(explode(':', $movieData['title'], 2)[0]);
+ $showName = $this->strFrom($movieData, 'show_name');
+ if ($showName === '' && str_contains($title, ':')) {
+ $showName = trim(explode(':', $title, 2)[0]);
}
- if (! empty($showName)) {
+ if ($showName !== '') {
$posterUrl = $tmdb->searchTVShowPosterByTitle($showName);
if ($posterUrl) {
$metadata = ['poster_url' => $posterUrl];
@@ -268,6 +326,8 @@ public function fetchSingleMovie(int $id): void
/**
* Return source services in priority order (excluding 'current').
+ *
+ * @return array
*/
protected function getOrderedSources(TmdbService $tmdb, TraktService $trakt): array
{
@@ -288,21 +348,28 @@ protected function getOrderedSources(TmdbService $tmdb, TraktService $trakt): ar
/**
* Fetch metadata from sources in priority order, merging to fill gaps.
+ *
+ * @param array $sources
+ * @param array $movieData
+ * @return array|null
*/
protected function fetchFromSources(array $sources, array $movieData): ?array
{
$merged = null;
+ $imdbId = $this->strFrom($movieData, 'imdb_id');
+ $title = $this->strFrom($movieData, 'title');
+ $year = is_numeric($movieData['year'] ?? null) ? (int) $movieData['year'] : null;
- foreach ($sources as $name => $service) {
+ foreach ($sources as $service) {
$result = null;
- if (! empty($movieData['imdb_id'])) {
- $result = $service->findByImdbId($movieData['imdb_id']);
+ if ($imdbId !== '') {
+ $result = $service->findByImdbId($imdbId);
}
// Only fall back to title search if no IMDb ID exists
- if (! $result && empty($movieData['imdb_id']) && ! empty($movieData['title'])) {
- $result = $service->searchByTitle($movieData['title'], $movieData['year'] ?? null);
+ if (! $result && $imdbId === '' && $title !== '') {
+ $result = $service->searchByTitle($title, $year);
}
if (! $result) {
@@ -326,51 +393,12 @@ protected function fetchFromSources(array $sources, array $movieData): ?array
public function startReview(int $id): void
{
- $movieData = collect($this->moviesNeedingEnrichment)->firstWhere('id', $id);
-
- if (! $movieData) {
- return;
- }
-
- $this->reviewingMovieId = $id;
- $this->reviewingMovie = $movieData;
- $this->reviewingMetadata = $this->fetchedData[$id] ?? null;
-
- $this->selectedFields = $this->getFieldsToApply($movieData, $this->reviewingMetadata);
-
- $this->showReviewModal = true;
- }
-
- protected function getFieldsToApply(array $movieData, ?array $metadata): array
- {
- if (! $metadata) {
- return [];
- }
-
- $fields = [];
- $currentFirst = $this->sourcePriority[0] === 'current';
-
- foreach (self::ENRICHABLE_FIELDS as $field) {
- $hasCurrentValue = ! empty($movieData['current'][$field]);
- $hasNewValue = ! empty($metadata[$field]);
-
- if (! $hasNewValue) {
- continue;
- }
-
- if (! $hasCurrentValue) {
- $fields[] = $field;
- } elseif (! $currentFirst) {
- $fields[] = $field;
- }
- }
-
- return $fields;
+ $this->openReviewFor($id);
}
public function applyMetadata(): void
{
- if (! $this->reviewingMovieId || ! $this->reviewingMetadata || empty($this->selectedFields)) {
+ if (! $this->reviewingMovieId || ! $this->reviewingMetadata || $this->selectedFields === []) {
$this->closeReviewModal();
return;
@@ -386,30 +414,27 @@ public function applyMetadata(): void
return;
}
- $updateData = [];
-
- foreach ($this->selectedFields as $field) {
- if (isset($this->reviewingMetadata[$field]) && $this->reviewingMetadata[$field] !== null) {
- $updateData[$field] = $this->reviewingMetadata[$field];
- }
- }
+ $updateData = $this->buildUpdateData();
- if (! empty($updateData)) {
+ if ($updateData !== []) {
$movie->update($updateData);
}
// Propagate show poster to siblings when applying to an episode or show
- $posterToPropagate = $updateData['poster_url'] ?? $movie->poster_url;
- $showNameToPropagate = $updateData['show_name'] ?? $movie->show_name;
- $isEpisodeOrShow = $movie->isLikelyEpisode() || in_array($movie->title_type, ['TV Series', 'TV Mini Series']);
+ $posterRaw = $updateData['poster_url'] ?? $movie->poster_url;
+ $posterToPropagate = is_string($posterRaw) ? $posterRaw : null;
+ $showNameRaw = $updateData['show_name'] ?? $movie->show_name;
+ $showNameToPropagate = is_string($showNameRaw) ? $showNameRaw : null;
+ $isEpisodeOrShow = $movie->isLikelyEpisode() || in_array($movie->title_type, ['TV Series', 'TV Mini Series'], true);
- if ($isEpisodeOrShow && $posterToPropagate) {
+ $propagated = 0;
+ if ($isEpisodeOrShow && $posterToPropagate !== null) {
$titlePrefix = str_contains($movie->title, ':')
? trim(explode(':', $movie->title, 2)[0])
: ($movie->title_type !== 'TV Episode' ? $movie->title : null);
$propagated = Movie::propagateShowPoster(
- Auth::id(),
+ (int) Auth::id(),
$showNameToPropagate,
$titlePrefix,
$posterToPropagate,
@@ -417,7 +442,7 @@ public function applyMetadata(): void
);
}
- $this->updateLocalMovieData($this->reviewingMovieId, $updateData);
+ $this->updateLocalItemData($this->reviewingMovieId, $updateData);
$this->closeReviewModal();
@@ -433,35 +458,6 @@ public function skipMovie(): void
$this->closeReviewModal();
}
- public function closeReviewModal(): void
- {
- $this->showReviewModal = false;
- $this->reviewingMovieId = null;
- $this->reviewingMovie = null;
- $this->reviewingMetadata = null;
- $this->selectedFields = [];
- }
-
- protected function updateLocalMovieData(int $movieId, array $updateData): void
- {
- foreach ($this->moviesNeedingEnrichment as $index => $movieData) {
- if ($movieData['id'] === $movieId) {
- foreach ($updateData as $field => $value) {
- $this->moviesNeedingEnrichment[$index]['current'][$field] = $value;
-
- $missingIndex = array_search($field, $this->moviesNeedingEnrichment[$index]['missing']);
- if ($missingIndex !== false) {
- unset($this->moviesNeedingEnrichment[$index]['missing'][$missingIndex]);
- $this->moviesNeedingEnrichment[$index]['missing'] = array_values($this->moviesNeedingEnrichment[$index]['missing']);
- }
- }
-
- $this->moviesNeedingEnrichment[$index]['has_missing'] = ! empty($this->moviesNeedingEnrichment[$index]['missing']);
- break;
- }
- }
- }
-
public function getSourceLabel(string $source): string
{
return match ($source) {
@@ -472,38 +468,21 @@ public function getSourceLabel(string $source): string
};
}
- public function getMoviesWithMissingCount(): int
- {
- return collect($this->moviesNeedingEnrichment)->where('has_missing', true)->count();
- }
-
- public function getFetchedCount(): int
- {
- return count($this->fetchedData);
- }
-
- public function isJobRunning(): bool
- {
- return $this->jobStatus && $this->jobStatus['status'] === 'running';
- }
-
- public function isJobCompleted(): bool
- {
- return $this->jobStatus && $this->jobStatus['status'] === 'completed';
- }
-
public function setActiveTab(string $tab): void
{
$this->activeTab = $tab;
}
+ /**
+ * @return array>
+ */
public function getFilteredMovies(): array
{
if ($this->activeTab === 'tv') {
return collect($this->moviesNeedingEnrichment)
- ->filter(fn ($m) => in_array($m['title_type'] ?? '', ['TV Series', 'TV Mini Series', 'TV Episode']))
+ ->filter(fn ($m): bool => in_array($m['title_type'] ?? '', ['TV Series', 'TV Mini Series', 'TV Episode'], true))
->values()
- ->toArray();
+ ->all();
}
if ($this->activeTab === 'orphans') {
@@ -516,7 +495,7 @@ public function getFilteredMovies(): array
public function getTvCount(): int
{
return collect($this->moviesNeedingEnrichment)
- ->filter(fn ($m) => in_array($m['title_type'] ?? '', ['TV Series', 'TV Mini Series', 'TV Episode']))
+ ->filter(fn ($m): bool => in_array($m['title_type'] ?? '', ['TV Series', 'TV Mini Series', 'TV Episode'], true))
->count();
}
@@ -535,7 +514,7 @@ public function scanOrphanEpisodes(): void
$orphans = Movie::query()
->where('user_id', $userId)
->where('title_type', 'TV Episode')
- ->where(function ($query) use ($seriesTitles) {
+ ->where(function ($query) use ($seriesTitles): void {
$query->whereNull('show_name')
->orWhere('show_name', '');
if (! empty($seriesTitles)) {
@@ -544,7 +523,7 @@ public function scanOrphanEpisodes(): void
})
->orderBy('title')
->get(['id', 'title', 'title_type', 'imdb_id', 'year', 'show_name', 'season_number', 'episode_number', 'poster_url'])
- ->map(fn ($movie) => [
+ ->map(fn ($movie): array => [
'id' => $movie->id,
'title' => $movie->title,
'imdb_id' => $movie->imdb_id,
@@ -553,7 +532,7 @@ public function scanOrphanEpisodes(): void
'season_episode' => $movie->season_episode_label,
'has_show_name' => ! empty($movie->show_name),
])
- ->toArray();
+ ->all();
$this->orphanEpisodes = $orphans;
}
@@ -581,10 +560,10 @@ public function linkOrphanToShow(int $movieId, string $showName): void
$propagated = Movie::query()
->where('user_id', Auth::id())
->where('title_type', 'TV Episode')
- ->where(function ($q) use ($titlePrefix) {
- $q->where('title', 'like', $titlePrefix . ':%');
+ ->where(function ($q) use ($titlePrefix): void {
+ $q->where('title', 'like', $titlePrefix.':%');
})
- ->where(function ($q) {
+ ->where(function ($q): void {
$q->whereNull('show_name')->orWhere('show_name', '');
})
->update(['show_name' => $showName]);
@@ -600,13 +579,13 @@ public function linkOrphanToShow(int $movieId, string $showName): void
session()->flash('message', $msg);
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
if ($this->jobStatus && $this->jobStatus['status'] === 'running') {
$this->refreshJobStatus();
}
- return view('livewire.movies.movie-metadata-enrichment')
- ->layout('layouts.app');
+ return view('livewire.movies.movie-metadata-enrichment');
}
}
diff --git a/app/Livewire/Movies/MovieSettings.php b/app/Livewire/Movies/MovieSettings.php
index e6b860a..fb83f9f 100644
--- a/app/Livewire/Movies/MovieSettings.php
+++ b/app/Livewire/Movies/MovieSettings.php
@@ -5,7 +5,9 @@
namespace App\Livewire\Movies;
use App\Models\Movie;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class MovieSettings extends Component
@@ -37,9 +39,10 @@ public function deleteAllMovies(): void
return;
}
- $count = Movie::query()
+ $deleted = Movie::query()
->where('user_id', Auth::id())
->delete();
+ $count = is_int($deleted) ? $deleted : 0;
$this->showDeleteAllModal = false;
$this->confirmationInput = '';
@@ -70,9 +73,9 @@ protected function generateConfirmationWord(): string
return implode('', $chars);
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
- return view('livewire.movies.movie-settings')
- ->layout('layouts.app');
+ return view('livewire.movies.movie-settings');
}
}
diff --git a/app/Livewire/Movies/MovieShow.php b/app/Livewire/Movies/MovieShow.php
index e20f6a9..ff1746b 100644
--- a/app/Livewire/Movies/MovieShow.php
+++ b/app/Livewire/Movies/MovieShow.php
@@ -7,7 +7,12 @@
use App\Enums\WatchingStatus;
use App\Models\Movie;
use App\Services\TmdbService;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class MovieShow extends Component
@@ -20,6 +25,7 @@ class MovieShow extends Component
public bool $showPosterForm = false;
+ /** @var array|null */
public ?array $fetchedMetadata = null;
public bool $showMetadataPreview = false;
@@ -74,11 +80,13 @@ public function savePosterUrl(): void
if ($url === '') {
$this->showPosterForm = false;
+
return;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$this->addError('posterUrlInput', 'Please enter a valid URL.');
+
return;
}
@@ -104,6 +112,7 @@ public function fetchMetadata(): void
$service = app(TmdbService::class);
if (! $service->isConfigured()) {
session()->flash('error', 'TMDB is not configured.');
+
return;
}
@@ -140,6 +149,7 @@ public function fetchMetadata(): void
if (! $metadata) {
session()->flash('error', 'No metadata found on TMDB for this entry.');
+
return;
}
@@ -172,14 +182,16 @@ public function applyMetadata(): void
$updates['year'] = $this->fetchedMetadata['year'];
}
- if (! empty($updates)) {
+ if ($updates !== []) {
$updates['metadata_fetched_at'] = now();
$this->movie->update($updates);
// Propagate poster to siblings
- $posterUrl = $updates['poster_url'] ?? $this->movie->poster_url;
- $showName = $updates['show_name'] ?? $this->movie->show_name;
- if ($posterUrl && ($this->movie->isLikelyEpisode() || in_array($this->movie->title_type, ['TV Series', 'TV Mini Series']))) {
+ $posterRaw = $updates['poster_url'] ?? $this->movie->poster_url;
+ $posterUrl = is_string($posterRaw) ? $posterRaw : null;
+ $showNameRaw = $updates['show_name'] ?? $this->movie->show_name;
+ $showName = is_string($showNameRaw) ? $showNameRaw : null;
+ if ($posterUrl !== null && ($this->movie->isLikelyEpisode() || in_array($this->movie->title_type, ['TV Series', 'TV Mini Series'], true))) {
$titlePrefix = str_contains($this->movie->title, ':')
? trim(explode(':', $this->movie->title, 2)[0])
: ($this->movie->title_type !== 'TV Episode' ? $this->movie->title : null);
@@ -209,15 +221,21 @@ public function dismissMetadata(): void
$this->showMetadataPreview = false;
}
+ /**
+ * @return list
+ */
public function getStatuses(): array
{
return WatchingStatus::cases();
}
- public function getSiblingEpisodes()
+ /**
+ * @return Collection
+ */
+ public function getSiblingEpisodes(): Collection
{
if (! $this->movie->isLikelyEpisode()) {
- return collect();
+ return new Collection;
}
$query = Movie::where('user_id', $this->movie->user_id)
@@ -227,11 +245,11 @@ public function getSiblingEpisodes()
if ($this->movie->show_name) {
$query->where('show_name', $this->movie->show_name);
} else {
- $prefix = \Illuminate\Support\Str::before($this->movie->title, ':');
+ $prefix = Str::before($this->movie->title, ':');
if ($prefix !== $this->movie->title) {
- $query->where('title', 'like', $prefix . ':%');
+ $query->where('title', 'like', $prefix.':%');
} else {
- return collect();
+ return new Collection;
}
}
@@ -240,12 +258,12 @@ public function getSiblingEpisodes()
$query->where('season_number', $this->movie->season_number);
}
- $driver = \Illuminate\Support\Facades\DB::getDriverName();
+ $driver = DB::getDriverName();
$nullLast = $driver === 'pgsql' ? 'NULLS LAST' : '';
return $query
- ->orderByRaw($driver === 'sqlite' ? 'season_number IS NULL, season_number ASC' : 'season_number ASC ' . $nullLast)
- ->orderByRaw($driver === 'sqlite' ? 'episode_number IS NULL, episode_number ASC' : 'episode_number ASC ' . $nullLast)
+ ->orderByRaw($driver === 'sqlite' ? 'season_number IS NULL, season_number ASC' : 'season_number ASC '.$nullLast)
+ ->orderByRaw($driver === 'sqlite' ? 'episode_number IS NULL, episode_number ASC' : 'episode_number ASC '.$nullLast)
->orderBy('title', 'asc')
->get();
}
@@ -253,7 +271,7 @@ public function getSiblingEpisodes()
public function getShowName(): string
{
return $this->movie->show_name
- ?? \Illuminate\Support\Str::before($this->movie->title, ':');
+ ?? Str::before($this->movie->title, ':');
}
public function getParentShow(): ?Movie
@@ -268,14 +286,15 @@ public function getParentShow(): ?Movie
return Movie::where('user_id', $this->movie->user_id)
->where('id', '!=', $this->movie->id)
->whereIn('title_type', ['TV Series', 'TV Mini Series'])
- ->where(function ($q) use ($showName) {
+ ->where(function ($q) use ($showName): void {
$q->where('title', $showName)
->orWhere('show_name', $showName);
})
->first();
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
$isSeries = in_array($this->movie->title_type, ['TV Series', 'TV Mini Series']);
$allEpisodes = collect();
@@ -306,6 +325,6 @@ public function render()
'allEpisodes' => $allEpisodes,
'showName' => $showName,
'parentShow' => $this->getParentShow(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/Movies/MovieTmdbSearch.php b/app/Livewire/Movies/MovieTmdbSearch.php
index 27caad3..0ea8851 100644
--- a/app/Livewire/Movies/MovieTmdbSearch.php
+++ b/app/Livewire/Movies/MovieTmdbSearch.php
@@ -7,8 +7,10 @@
use App\Enums\WatchingStatus;
use App\Models\Movie;
use App\Services\TmdbService;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class MovieTmdbSearch extends Component
@@ -17,36 +19,63 @@ class MovieTmdbSearch extends Component
// Search state
public string $query = '';
+
+ /** @var array */
public array $searchResults = [];
+
public int $totalPages = 0;
+
public int $currentPage = 1;
// Selected result details
public ?string $selectedMediaType = null;
+
public ?int $selectedTmdbId = null;
// Movie configuration
public string $title = '';
+
public string $original_title = '';
+
public string $director = '';
+
public ?int $year = null;
+
public ?int $runtime_minutes = null;
+
public string $genres = '';
+
public string $description = '';
+
public string $poster_url = '';
+
public string $imdb_id = '';
+
public string $status = 'watchlist';
+
public ?int $rating = null;
// TV show state
+ /** @var array */
public array $showData = [];
+
+ /** @var array */
public array $seasons = [];
- public array $loadedEpisodes = []; // season_number => episodes[]
- public array $selectedEpisodes = []; // "S{n}E{n}" => true
- public array $watchedEpisodes = []; // "S{n}E{n}" => true
+
+ /** @var array>> season_number => episodes[] */
+ public array $loadedEpisodes = [];
+
+ /** @var array "S{n}E{n}" => true */
+ public array $selectedEpisodes = [];
+
+ /** @var array "S{n}E{n}" => true */
+ public array $watchedEpisodes = [];
// Duplicate detection
+ /** @var array */
public array $existingImdbIds = [];
+
+ /** @var array */
public array $existingEpisodeKeys = [];
public function mount(): void
@@ -63,7 +92,7 @@ public function mount(): void
->whereNotNull('season_number')
->whereNotNull('episode_number')
->get(['show_name', 'season_number', 'episode_number'])
- ->map(fn ($m) => $m->show_name . '|' . $m->season_number . '|' . $m->episode_number)
+ ->map(fn ($m): string => $m->show_name.'|'.$m->season_number.'|'.$m->episode_number)
->all();
}
@@ -77,8 +106,8 @@ public function search(): void
$tmdb = app(TmdbService::class);
$result = $tmdb->searchMulti($query, 1);
- $this->searchResults = $result['results'];
- $this->totalPages = $result['total_pages'];
+ $this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
+ $this->totalPages = is_int($result['total_pages'] ?? null) ? $result['total_pages'] : 0;
$this->currentPage = 1;
$this->step = 'results';
}
@@ -88,7 +117,7 @@ public function loadPage(int $page): void
$tmdb = app(TmdbService::class);
$result = $tmdb->searchMulti(trim($this->query), $page);
- $this->searchResults = $result['results'];
+ $this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
$this->currentPage = $page;
}
@@ -103,18 +132,19 @@ public function selectResult(int $tmdbId, string $mediaType): void
$details = $tmdb->fetchMovieDetails($tmdbId);
if (! $details) {
session()->flash('error', 'Could not fetch movie details from TMDB.');
+
return;
}
- $this->title = $details['title'] ?? '';
- $this->original_title = $details['original_title'] ?? '';
- $this->director = $details['director'] ?? '';
- $this->year = $details['year'];
- $this->runtime_minutes = $details['runtime_minutes'];
- $this->genres = $details['genres'] ?? '';
- $this->description = $details['description'] ?? '';
- $this->poster_url = $details['poster_url'] ?? '';
- $this->imdb_id = $details['imdb_id'] ?? '';
+ $this->title = $this->strOf($details['title'] ?? null);
+ $this->original_title = $this->strOf($details['original_title'] ?? null);
+ $this->director = $this->strOf($details['director'] ?? null);
+ $this->year = $this->intOrNull($details['year'] ?? null);
+ $this->runtime_minutes = $this->intOrNull($details['runtime_minutes'] ?? null);
+ $this->genres = $this->strOf($details['genres'] ?? null);
+ $this->description = $this->strOf($details['description'] ?? null);
+ $this->poster_url = $this->strOf($details['poster_url'] ?? null);
+ $this->imdb_id = $this->strOf($details['imdb_id'] ?? null);
$this->status = 'watchlist';
$this->rating = null;
@@ -123,20 +153,21 @@ public function selectResult(int $tmdbId, string $mediaType): void
$details = $tmdb->fetchTVSeasons($tmdbId);
if (! $details) {
session()->flash('error', 'Could not fetch TV show details from TMDB.');
+
return;
}
$this->showData = $details;
- $this->seasons = $details['seasons'] ?? [];
- $this->title = $details['title'] ?? '';
- $this->original_title = $details['original_title'] ?? '';
- $this->genres = $details['genres'] ?? '';
- $this->description = $details['description'] ?? '';
- $this->poster_url = $details['poster_url'] ?? '';
- $this->imdb_id = $details['imdb_id'] ?? '';
- $this->director = $details['director'] ?? '';
- $this->runtime_minutes = $details['runtime_minutes'];
- $this->year = $details['year'];
+ $this->seasons = is_array($details['seasons'] ?? null) ? $details['seasons'] : [];
+ $this->title = $this->strOf($details['title'] ?? null);
+ $this->original_title = $this->strOf($details['original_title'] ?? null);
+ $this->genres = $this->strOf($details['genres'] ?? null);
+ $this->description = $this->strOf($details['description'] ?? null);
+ $this->poster_url = $this->strOf($details['poster_url'] ?? null);
+ $this->imdb_id = $this->strOf($details['imdb_id'] ?? null);
+ $this->director = $this->strOf($details['director'] ?? null);
+ $this->runtime_minutes = $this->intOrNull($details['runtime_minutes'] ?? null);
+ $this->year = $this->intOrNull($details['year'] ?? null);
$this->status = 'watchlist';
$this->rating = null;
$this->loadedEpisodes = [];
@@ -179,12 +210,11 @@ public function addMovie(): void
public function loadSeasonEpisodes(int $seasonNumber): void
{
- if (isset($this->loadedEpisodes[$seasonNumber])) {
+ if (isset($this->loadedEpisodes[$seasonNumber]) || $this->selectedTmdbId === null) {
return;
}
- $tmdb = app(TmdbService::class);
- $episodes = $tmdb->fetchTVSeasonEpisodes($this->selectedTmdbId, $seasonNumber);
+ $episodes = app(TmdbService::class)->fetchTVSeasonEpisodes($this->selectedTmdbId, $seasonNumber);
if ($episodes) {
$this->loadedEpisodes[$seasonNumber] = $episodes;
@@ -193,35 +223,45 @@ public function loadSeasonEpisodes(int $seasonNumber): void
public function loadAllSeasons(): void
{
+ if ($this->selectedTmdbId === null) {
+ return;
+ }
+
$tmdb = app(TmdbService::class);
foreach ($this->seasons as $season) {
- $seasonNumber = $season['season_number'];
- if (! isset($this->loadedEpisodes[$seasonNumber])) {
- $episodes = $tmdb->fetchTVSeasonEpisodes($this->selectedTmdbId, $seasonNumber);
- if ($episodes) {
- $this->loadedEpisodes[$seasonNumber] = $episodes;
- }
+ $seasonNumber = is_array($season) ? $this->intOrNull($season['season_number'] ?? null) : null;
+ if ($seasonNumber === null) {
+ continue;
+ }
+ if (isset($this->loadedEpisodes[$seasonNumber])) {
+ continue;
+ }
+ $episodes = $tmdb->fetchTVSeasonEpisodes($this->selectedTmdbId, $seasonNumber);
+ if ($episodes) {
+ $this->loadedEpisodes[$seasonNumber] = $episodes;
}
}
}
public function selectAllEpisodes(): void
{
- if (empty($this->loadedEpisodes)) {
+ if ($this->loadedEpisodes === []) {
$this->loadAllSeasons();
}
foreach ($this->loadedEpisodes as $seasonNum => $episodes) {
foreach ($episodes as $ep) {
- $key = "S{$seasonNum}E{$ep['episode_number']}";
- $this->selectedEpisodes[$key] = true;
+ $key = $this->episodeKey($seasonNum, $ep);
+ if ($key !== null) {
+ $this->selectedEpisodes[$key] = true;
+ }
}
}
}
public function goToSelectEpisodes(): void
{
- if (empty($this->loadedEpisodes)) {
+ if ($this->loadedEpisodes === []) {
$this->loadAllSeasons();
}
$this->step = 'select_episodes';
@@ -257,7 +297,10 @@ public function selectAllSeason(int $seasonNumber): void
$allSelected = $this->isSeasonFullySelected($seasonNumber);
foreach ($episodes as $ep) {
- $key = "S{$seasonNumber}E{$ep['episode_number']}";
+ $key = $this->episodeKey($seasonNumber, $ep);
+ if ($key === null) {
+ continue;
+ }
if ($allSelected) {
unset($this->selectedEpisodes[$key]);
unset($this->watchedEpisodes[$key]);
@@ -269,19 +312,22 @@ public function selectAllSeason(int $seasonNumber): void
public function markSeasonWatched(int $seasonNumber): void
{
- $episodes = $this->loadedEpisodes[$seasonNumber] ?? [];
- foreach ($episodes as $ep) {
- $key = "S{$seasonNumber}E{$ep['episode_number']}";
- $this->selectedEpisodes[$key] = true;
- $this->watchedEpisodes[$key] = true;
+ foreach ($this->loadedEpisodes[$seasonNumber] ?? [] as $ep) {
+ $key = $this->episodeKey($seasonNumber, $ep);
+ if ($key !== null) {
+ $this->selectedEpisodes[$key] = true;
+ $this->watchedEpisodes[$key] = true;
+ }
}
}
public function markSeasonWatchlist(int $seasonNumber): void
{
- $episodes = $this->loadedEpisodes[$seasonNumber] ?? [];
- foreach ($episodes as $ep) {
- $key = "S{$seasonNumber}E{$ep['episode_number']}";
+ foreach ($this->loadedEpisodes[$seasonNumber] ?? [] as $ep) {
+ $key = $this->episodeKey($seasonNumber, $ep);
+ if ($key === null) {
+ continue;
+ }
$this->selectedEpisodes[$key] = true;
if (isset($this->watchedEpisodes[$key])) {
unset($this->watchedEpisodes[$key]);
@@ -296,21 +342,25 @@ public function isSeasonFullySelected(int $seasonNumber): bool
return false;
}
foreach ($episodes as $ep) {
- $key = "S{$seasonNumber}E{$ep['episode_number']}";
- if (! isset($this->selectedEpisodes[$key])) {
+ $key = $this->episodeKey($seasonNumber, $ep);
+ if ($key === null || ! isset($this->selectedEpisodes[$key])) {
return false;
}
}
+
return true;
}
public function isEpisodeDuplicate(int $seasonNumber, int $episodeNumber): bool
{
- $showName = $this->title;
- $key = $showName . '|' . $seasonNumber . '|' . $episodeNumber;
+ $key = $this->title.'|'.$seasonNumber.'|'.$episodeNumber;
+
return in_array($key, $this->existingEpisodeKeys);
}
+ /**
+ * @return array{selected: int, watched: int, watchlist: int}
+ */
public function getSelectionSummaryProperty(): array
{
$selected = count($this->selectedEpisodes);
@@ -326,23 +376,24 @@ public function getSelectionSummaryProperty(): array
public function importTVShow(): void
{
- if (empty($this->selectedEpisodes)) {
+ if ($this->selectedEpisodes === []) {
session()->flash('error', 'No episodes selected.');
+
return;
}
- $userId = Auth::id();
+ $userId = (int) Auth::id();
$showName = $this->title;
$posterUrl = $this->poster_url ?: null;
$now = now();
$imported = 0;
$skipped = 0;
- DB::transaction(function () use ($userId, $showName, $posterUrl, $now, &$imported, &$skipped) {
+ DB::transaction(function () use ($userId, $showName, $posterUrl, $now, &$imported, &$skipped): void {
// Create parent show entry if not already present
$existingShow = Movie::where('user_id', $userId)
->where('title_type', 'TV Series')
- ->where(function ($q) use ($showName) {
+ ->where(function ($q) use ($showName): void {
$q->where('show_name', $showName)
->orWhere('title', $showName);
})
@@ -370,7 +421,7 @@ public function importTVShow(): void
}
// Create episode entries
- foreach ($this->selectedEpisodes as $key => $_) {
+ foreach (array_keys($this->selectedEpisodes) as $key) {
if (! preg_match('/^S(\d+)E(\d+)$/', $key, $m)) {
continue;
}
@@ -378,22 +429,23 @@ public function importTVShow(): void
$episodeNum = (int) $m[2];
// Skip duplicates
- $dupKey = $showName . '|' . $seasonNum . '|' . $episodeNum;
+ $dupKey = $showName.'|'.$seasonNum.'|'.$episodeNum;
if (in_array($dupKey, $this->existingEpisodeKeys)) {
$skipped++;
+
continue;
}
// Find episode data from loaded episodes
$epData = null;
foreach ($this->loadedEpisodes[$seasonNum] ?? [] as $ep) {
- if ($ep['episode_number'] === $episodeNum) {
+ if (($ep['episode_number'] ?? null) === $episodeNum) {
$epData = $ep;
break;
}
}
- $episodeName = $epData['name'] ?? "Episode {$episodeNum}";
+ $episodeName = is_string($epData['name'] ?? null) ? $epData['name'] : "Episode {$episodeNum}";
$isWatched = isset($this->watchedEpisodes[$key]);
Movie::create([
@@ -448,19 +500,45 @@ public function backToConfigureTV(): void
$this->step = 'configure_tv';
}
+ /**
+ * @param array $result
+ */
public function isResultInLibrary(array $result): bool
{
// Can't check without imdb_id at search result level - will show as available
return false;
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
$summary = $this->getSelectionSummaryProperty();
return view('livewire.movies.movie-tmdb-search', [
'statuses' => WatchingStatus::cases(),
'summary' => $summary,
- ])->layout('layouts.app');
+ ]);
+ }
+
+ /**
+ * Build the "S{season}E{episode}" key from a loaded-episode payload.
+ *
+ * @param array $ep
+ */
+ private function episodeKey(int $seasonNumber, array $ep): ?string
+ {
+ $number = $ep['episode_number'] ?? null;
+
+ return is_numeric($number) ? 'S'.$seasonNumber.'E'.(int) $number : null;
+ }
+
+ private function strOf(mixed $value): string
+ {
+ return is_string($value) ? $value : '';
+ }
+
+ private function intOrNull(mixed $value): ?int
+ {
+ return is_numeric($value) ? (int) $value : null;
}
}
diff --git a/app/Livewire/Playing/PlayingIndex.php b/app/Livewire/Playing/PlayingIndex.php
new file mode 100644
index 0000000..30ef1a4
--- /dev/null
+++ b/app/Livewire/Playing/PlayingIndex.php
@@ -0,0 +1,45 @@
+>
+ */
+ public function getSubcategories(): array
+ {
+ return [
+ [
+ 'name' => 'Games',
+ 'icon' => 'game-controller',
+ 'description' => 'Video games across all platforms',
+ 'route' => 'games.index',
+ 'active' => true,
+ 'color' => 'green',
+ ],
+ [
+ 'name' => 'Board Games',
+ 'icon' => 'dice',
+ 'description' => 'Board games and tabletop games',
+ 'route' => 'board-games.index',
+ 'active' => true,
+ 'color' => 'amber',
+ ],
+ ];
+ }
+
+ #[Layout('layouts.app')]
+ public function render(): View
+ {
+ return view('livewire.playing.playing-index', [
+ 'subcategories' => $this->getSubcategories(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Reading/ReadingIndex.php b/app/Livewire/Reading/ReadingIndex.php
index 1985900..d0e9152 100644
--- a/app/Livewire/Reading/ReadingIndex.php
+++ b/app/Livewire/Reading/ReadingIndex.php
@@ -4,11 +4,14 @@
namespace App\Livewire\Reading;
+use Illuminate\Contracts\View\View;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class ReadingIndex extends Component
{
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.reading.reading-index', [
'subcategories' => [
@@ -29,6 +32,6 @@ public function render()
'color' => 'purple',
],
],
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Livewire/ThemeSwitcher.php b/app/Livewire/ThemeSwitcher.php
index 42e4e89..34d4c2f 100644
--- a/app/Livewire/ThemeSwitcher.php
+++ b/app/Livewire/ThemeSwitcher.php
@@ -4,6 +4,8 @@
namespace App\Livewire;
+use App\Models\User;
+use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
@@ -13,12 +15,23 @@ class ThemeSwitcher extends Component
public function mount(): void
{
- $this->theme = Auth::user()?->theme ?? config('themes.default', 'normie');
+ $user = Auth::user();
+ $theme = $user instanceof User ? $user->theme : null;
+
+ if (is_string($theme) && $theme !== '') {
+ $this->theme = $theme;
+
+ return;
+ }
+
+ $default = config('themes.default', 'normie');
+ $this->theme = is_string($default) ? $default : 'normie';
}
public function setTheme(string $theme): void
{
- $availableThemes = collect(config('themes.available'))->pluck('value')->toArray();
+ $available = config('themes.available');
+ $availableThemes = collect(is_array($available) ? $available : [])->pluck('value')->toArray();
if (! in_array($theme, $availableThemes)) {
return;
@@ -26,14 +39,15 @@ public function setTheme(string $theme): void
$this->theme = $theme;
- if (Auth::check()) {
- Auth::user()->update(['theme' => $theme]);
+ $user = Auth::user();
+ if ($user instanceof User) {
+ $user->update(['theme' => $theme]);
}
$this->dispatch('theme-changed', theme: $theme);
}
- public function render()
+ public function render(): View
{
return view('livewire.theme-switcher', [
'themes' => config('themes.available'),
diff --git a/app/Livewire/Watching/WatchingIndex.php b/app/Livewire/Watching/WatchingIndex.php
index 3b943c0..d22b83c 100644
--- a/app/Livewire/Watching/WatchingIndex.php
+++ b/app/Livewire/Watching/WatchingIndex.php
@@ -4,10 +4,15 @@
namespace App\Livewire\Watching;
+use Illuminate\Contracts\View\View;
+use Livewire\Attributes\Layout;
use Livewire\Component;
class WatchingIndex extends Component
{
+ /**
+ * @return list>
+ */
public function getSubcategories(): array
{
return [
@@ -30,10 +35,11 @@ public function getSubcategories(): array
];
}
- public function render()
+ #[Layout('layouts.app')]
+ public function render(): View
{
return view('livewire.watching.watching-index', [
'subcategories' => $this->getSubcategories(),
- ])->layout('layouts.app');
+ ]);
}
}
diff --git a/app/Models/Album.php b/app/Models/Album.php
new file mode 100644
index 0000000..9fa6da6
--- /dev/null
+++ b/app/Models/Album.php
@@ -0,0 +1,71 @@
+|null $genre
+ * @property array|null $styles
+ * @property array|null $tracklist
+ */
+class Album extends Model
+{
+ /** @use HasFactory */
+ use HasFactory;
+
+ protected $fillable = [
+ 'user_id',
+ 'title',
+ 'artist',
+ 'genre',
+ 'styles',
+ 'year',
+ 'format',
+ 'label',
+ 'country',
+ 'cover_url',
+ 'tracklist',
+ 'status',
+ 'ownership',
+ 'rating',
+ 'discogs_id',
+ 'discogs_master_id',
+ 'notes',
+ ];
+
+ /**
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => CollectionStatus::class,
+ 'ownership' => OwnershipStatus::class,
+ 'genre' => 'array',
+ 'styles' => 'array',
+ 'tracklist' => 'array',
+ 'rating' => 'integer',
+ 'year' => 'integer',
+ 'discogs_id' => 'integer',
+ 'discogs_master_id' => 'integer',
+ ];
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/Anime.php b/app/Models/Anime.php
index 86fe925..ab5668e 100644
--- a/app/Models/Anime.php
+++ b/app/Models/Anime.php
@@ -5,12 +5,23 @@
namespace App\Models;
use App\Enums\WatchingStatus;
+use Database\Factories\AnimeFactory;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
-
+use Illuminate\Support\Carbon;
+
+/**
+ * @property WatchingStatus $status
+ * @property Carbon|null $date_started
+ * @property Carbon|null $date_finished
+ * @property Carbon|null $date_added
+ * @property Carbon|null $metadata_fetched_at
+ */
class Anime extends Model
{
+ /** @use HasFactory */
use HasFactory;
protected $table = 'anime';
@@ -42,6 +53,9 @@ class Anime extends Model
'metadata_fetched_at',
];
+ /**
+ * @return array
+ */
protected function casts(): array
{
return [
@@ -59,43 +73,62 @@ protected function casts(): array
];
}
+ /**
+ * @return BelongsTo
+ */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
- public function scopeForUser($query, User $user)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeForUser(Builder $query, User $user): Builder
{
return $query->where('user_id', $user->id);
}
- public function scopeWithStatus($query, WatchingStatus $status)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeWithStatus(Builder $query, WatchingStatus $status): Builder
{
return $query->where('status', $status);
}
+ /**
+ * @return array
+ */
public function getGenreListAttribute(): array
{
- if (empty($this->genres)) {
+ $genres = $this->genres;
+ if (! is_string($genres) || $genres === '') {
return [];
}
- return collect(explode(',', $this->genres))
- ->map(fn ($genre) => trim($genre))
- ->filter(fn ($genre) => $genre !== '')
+ return collect(explode(',', $genres))
+ ->map(fn ($genre): string => trim((string) $genre))
+ ->filter(fn ($genre): bool => $genre !== '')
->values()
->all();
}
+ /**
+ * @return array
+ */
public function getStudioListAttribute(): array
{
- if (empty($this->studios)) {
+ $studios = $this->studios;
+ if (! is_string($studios) || $studios === '') {
return [];
}
- return collect(explode(',', $this->studios))
- ->map(fn ($studio) => trim($studio))
- ->filter(fn ($studio) => $studio !== '')
+ return collect(explode(',', $studios))
+ ->map(fn ($studio): string => trim((string) $studio))
+ ->filter(fn ($studio): bool => $studio !== '')
->values()
->all();
}
@@ -120,28 +153,34 @@ public function getRuntimeFormattedAttribute(): ?string
return "{$minutes}m";
}
+ /**
+ * @return array
+ */
public static function getAllGenresForUser(int $userId): array
{
return static::where('user_id', $userId)
->whereNotNull('genres')
->pluck('genres')
- ->flatMap(fn ($s) => explode(',', $s))
- ->map(fn ($s) => trim($s))
- ->filter(fn ($s) => $s !== '')
+ ->flatMap(fn ($s): array => is_string($s) ? explode(',', $s) : [])
+ ->map(fn ($s): string => trim((string) $s))
+ ->filter(fn ($s): bool => $s !== '')
->unique()
->sort()
->values()
->all();
}
+ /**
+ * @return array
+ */
public static function getAllStudiosForUser(int $userId): array
{
return static::where('user_id', $userId)
->whereNotNull('studios')
->pluck('studios')
- ->flatMap(fn ($s) => explode(',', $s))
- ->map(fn ($s) => trim($s))
- ->filter(fn ($s) => $s !== '')
+ ->flatMap(fn ($s): array => is_string($s) ? explode(',', $s) : [])
+ ->map(fn ($s): string => trim((string) $s))
+ ->filter(fn ($s): bool => $s !== '')
->unique()
->sort()
->values()
diff --git a/app/Models/BoardGame.php b/app/Models/BoardGame.php
new file mode 100644
index 0000000..387f061
--- /dev/null
+++ b/app/Models/BoardGame.php
@@ -0,0 +1,68 @@
+|null $genre
+ */
+class BoardGame extends Model
+{
+ /** @use HasFactory */
+ use HasFactory;
+
+ protected $fillable = [
+ 'user_id',
+ 'title',
+ 'genre',
+ 'description',
+ 'cover_url',
+ 'year_published',
+ 'designer',
+ 'publisher',
+ 'min_players',
+ 'max_players',
+ 'playing_time',
+ 'status',
+ 'rating',
+ 'bgg_rating',
+ 'plays',
+ 'bgg_id',
+ 'notes',
+ ];
+
+ /**
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => BoardGameStatus::class,
+ 'genre' => 'array',
+ 'rating' => 'integer',
+ 'bgg_rating' => 'decimal:2',
+ 'plays' => 'integer',
+ 'min_players' => 'integer',
+ 'max_players' => 'integer',
+ 'playing_time' => 'integer',
+ 'year_published' => 'integer',
+ 'bgg_id' => 'integer',
+ ];
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/Book.php b/app/Models/Book.php
index 377330a..d0be641 100644
--- a/app/Models/Book.php
+++ b/app/Models/Book.php
@@ -5,14 +5,24 @@
namespace App\Models;
use App\Enums\ReadingStatus;
+use Database\Factories\BookFactory;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Support\Carbon;
+/**
+ * @property ReadingStatus $status
+ * @property Carbon|null $published_date
+ * @property Carbon|null $date_started
+ * @property Carbon|null $date_finished
+ * @property Carbon|null $date_added
+ */
class Book extends Model
{
- /** @use HasFactory<\Database\Factories\BookFactory> */
+ /** @use HasFactory */
use HasFactory;
protected $fillable = [
@@ -37,7 +47,7 @@ class Book extends Model
'date_pub',
'date_pub_edition',
'date_started',
- 'date_recorded',
+ 'date_finished',
'date_added',
'shelves',
'notes',
@@ -47,6 +57,9 @@ class Book extends Model
'owned',
];
+ /**
+ * @return array
+ */
protected function casts(): array
{
return [
@@ -61,27 +74,41 @@ protected function casts(): array
'owned' => 'boolean',
'published_date' => 'date',
'date_started' => 'date',
- 'date_recorded' => 'date',
+ 'date_finished' => 'date',
'date_added' => 'date',
];
}
+ /**
+ * @return BelongsTo
+ */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
+ /**
+ * @return BelongsToMany
+ */
public function bookShelves(): BelongsToMany
{
return $this->belongsToMany(Shelf::class)->withTimestamps();
}
- public function scopeForUser($query, User $user)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeForUser(Builder $query, User $user): Builder
{
return $query->where('user_id', $user->id);
}
- public function scopeWithStatus($query, ReadingStatus $status)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeWithStatus(Builder $query, ReadingStatus $status): Builder
{
return $query->where('status', $status);
}
@@ -97,7 +124,7 @@ public function getPublishedYearAttribute(): ?string
}
// Fallback to date_pub if it's just a year
- if ($this->date_pub && preg_match('/^\d{4}/', $this->date_pub, $matches)) {
+ if (is_string($this->date_pub) && preg_match('/^\d{4}/', $this->date_pub, $matches)) {
return $matches[0];
}
@@ -106,6 +133,8 @@ public function getPublishedYearAttribute(): ?string
/**
* Status values that should be filtered out from tags.
+ *
+ * @var list
*/
protected static array $statusShelves = ['read', 'to-read', 'currently-reading', 'want-to-read'];
@@ -116,13 +145,14 @@ public function getPublishedYearAttribute(): ?string
*/
public function getTagsAttribute(): array
{
- if (empty($this->shelves)) {
+ $shelves = $this->shelves;
+ if (! is_string($shelves) || $shelves === '') {
return [];
}
- return collect(explode(',', $this->shelves))
- ->map(fn ($tag) => trim($tag))
- ->filter(fn ($tag) => $tag !== '' && !in_array(strtolower($tag), self::$statusShelves))
+ return collect(explode(',', $shelves))
+ ->map(fn ($tag): string => trim((string) $tag))
+ ->filter(fn ($tag): bool => $tag !== '' && ! in_array(strtolower((string) $tag), self::$statusShelves))
->values()
->all();
}
@@ -130,11 +160,11 @@ public function getTagsAttribute(): array
/**
* Set tags by updating the shelves field, preserving any status value.
*
- * @param array $tags
+ * @param array $tags
*/
public function setTagsFromArray(array $tags): void
{
- $currentParts = $this->shelves ? explode(',', $this->shelves) : [];
+ $currentParts = is_string($this->shelves) && $this->shelves !== '' ? explode(',', $this->shelves) : [];
$statusPart = null;
// Find and preserve status value
@@ -149,7 +179,7 @@ public function setTagsFromArray(array $tags): void
$newParts = $statusPart ? [$statusPart] : [];
foreach ($tags as $tag) {
$tag = trim($tag);
- if ($tag !== '' && !in_array(strtolower($tag), self::$statusShelves)) {
+ if ($tag !== '' && ! in_array(strtolower($tag), self::$statusShelves)) {
$newParts[] = $tag;
}
}
@@ -167,9 +197,9 @@ public static function getAllTagsForUser(int $userId): array
return static::where('user_id', $userId)
->whereNotNull('shelves')
->pluck('shelves')
- ->flatMap(fn ($s) => explode(',', $s))
- ->map(fn ($s) => trim($s))
- ->filter(fn ($s) => $s !== '' && !in_array(strtolower($s), self::$statusShelves))
+ ->flatMap(fn ($s): array => is_string($s) ? explode(',', $s) : [])
+ ->map(fn ($s): string => trim((string) $s))
+ ->filter(fn ($s): bool => $s !== '' && ! in_array(strtolower((string) $s), self::$statusShelves))
->unique()
->sort()
->values()
@@ -182,27 +212,28 @@ public static function getAllTagsForUser(int $userId): array
*/
public function getThumbnailUrl(int $size = 50): ?string
{
- if (empty($this->cover_url)) {
+ $coverUrl = $this->cover_url;
+ if (! is_string($coverUrl) || $coverUrl === '') {
return null;
}
// Local storage - return as-is (could add thumbnail generation later)
- if (str_starts_with($this->cover_url, '/storage/')) {
- return $this->cover_url;
+ if (str_starts_with($coverUrl, '/storage/')) {
+ return $coverUrl;
}
// GoodReads URLs - add size modifier
- if (str_contains($this->cover_url, 'gr-assets.com')) {
+ if (str_contains($coverUrl, 'gr-assets.com')) {
// Transform: image.jpg -> image._SX{size}_.jpg
return preg_replace(
'/(\.\w+)$/',
"._SX{$size}_$1",
- $this->cover_url
+ $coverUrl
);
}
// Other external URLs - return as-is
- return $this->cover_url;
+ return $coverUrl;
}
/**
diff --git a/app/Models/Comic.php b/app/Models/Comic.php
index 7944f84..13f9f7a 100644
--- a/app/Models/Comic.php
+++ b/app/Models/Comic.php
@@ -5,13 +5,24 @@
namespace App\Models;
use App\Enums\ReadingStatus;
+use Database\Factories\ComicFactory;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Support\Carbon;
+/**
+ * @property ReadingStatus $status
+ * @property Carbon|null $date_started
+ * @property Carbon|null $date_finished
+ * @property Carbon|null $date_added
+ * @property Carbon|null $metadata_fetched_at
+ */
class Comic extends Model
{
+ /** @use HasFactory */
use HasFactory;
protected $fillable = [
@@ -36,6 +47,9 @@ class Comic extends Model
'metadata_fetched_at',
];
+ /**
+ * @return array
+ */
protected function casts(): array
{
return [
@@ -50,41 +64,63 @@ protected function casts(): array
];
}
+ /**
+ * @return BelongsTo
+ */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
+ /**
+ * @return HasMany
+ */
public function issues(): HasMany
{
return $this->hasMany(ComicIssue::class);
}
- public function scopeForUser($query, User $user)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeForUser(Builder $query, User $user): Builder
{
return $query->where('user_id', $user->id);
}
- public function scopeWithStatus($query, ReadingStatus $status)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeWithStatus(Builder $query, ReadingStatus $status): Builder
{
return $query->where('status', $status);
}
+ /**
+ * @return array
+ */
public function getCreatorsArrayAttribute(): array
{
- if (empty($this->creators)) {
+ $creators = $this->creators;
+ if (! is_string($creators) || $creators === '') {
return [];
}
- return array_map('trim', explode(',', $this->creators));
+ return array_map(trim(...), explode(',', $creators));
}
+ /**
+ * @return array
+ */
public function getCharactersArrayAttribute(): array
{
- if (empty($this->characters)) {
+ $characters = $this->characters;
+ if (! is_string($characters) || $characters === '') {
return [];
}
- return array_map('trim', explode(',', $this->characters));
+ return array_map(trim(...), explode(',', $characters));
}
}
diff --git a/app/Models/ComicIssue.php b/app/Models/ComicIssue.php
index ecc80ce..7eb7580 100644
--- a/app/Models/ComicIssue.php
+++ b/app/Models/ComicIssue.php
@@ -5,12 +5,14 @@
namespace App\Models;
use App\Enums\ReadingStatus;
+use Database\Factories\ComicIssueFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ComicIssue extends Model
{
+ /** @use HasFactory */
use HasFactory;
protected $fillable = [
@@ -29,6 +31,9 @@ class ComicIssue extends Model
'notes',
];
+ /**
+ * @return array
+ */
protected function casts(): array
{
return [
@@ -39,11 +44,17 @@ protected function casts(): array
];
}
+ /**
+ * @return BelongsTo
+ */
public function comic(): BelongsTo
{
return $this->belongsTo(Comic::class);
}
+ /**
+ * @return BelongsTo
+ */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
diff --git a/app/Models/Concert.php b/app/Models/Concert.php
new file mode 100644
index 0000000..22b4007
--- /dev/null
+++ b/app/Models/Concert.php
@@ -0,0 +1,61 @@
+|null $setlist
+ */
+class Concert extends Model
+{
+ /** @use HasFactory */
+ use HasFactory;
+
+ protected $fillable = [
+ 'user_id',
+ 'artist',
+ 'tour_name',
+ 'venue',
+ 'city',
+ 'country',
+ 'event_date',
+ 'setlist',
+ 'cover_url',
+ 'status',
+ 'rating',
+ 'setlist_fm_id',
+ 'artist_mbid',
+ 'notes',
+ ];
+
+ /**
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => ListeningStatus::class,
+ 'setlist' => 'array',
+ 'rating' => 'integer',
+ 'event_date' => 'date',
+ ];
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/Episode.php b/app/Models/Episode.php
index f067d0f..3599252 100644
--- a/app/Models/Episode.php
+++ b/app/Models/Episode.php
@@ -4,12 +4,14 @@
namespace App\Models;
+use Database\Factories\EpisodeFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Episode extends Model
{
+ /** @use HasFactory */
use HasFactory;
protected $fillable = [
@@ -32,6 +34,9 @@ class Episode extends Model
'year',
];
+ /**
+ * @return array
+ */
protected function casts(): array
{
return [
@@ -48,11 +53,17 @@ protected function casts(): array
];
}
+ /**
+ * @return BelongsTo
+ */
public function show(): BelongsTo
{
return $this->belongsTo(Show::class);
}
+ /**
+ * @return BelongsTo
+ */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
@@ -64,7 +75,7 @@ public function getSeasonEpisodeLabelAttribute(): ?string
return null;
}
- return 'S' . str_pad((string) $this->season_number, 2, '0', STR_PAD_LEFT)
- . 'E' . str_pad((string) $this->episode_number, 2, '0', STR_PAD_LEFT);
+ return 'S'.str_pad((string) $this->season_number, 2, '0', STR_PAD_LEFT)
+ .'E'.str_pad((string) $this->episode_number, 2, '0', STR_PAD_LEFT);
}
}
diff --git a/app/Models/Game.php b/app/Models/Game.php
new file mode 100644
index 0000000..8a84d74
--- /dev/null
+++ b/app/Models/Game.php
@@ -0,0 +1,81 @@
+|null $platform
+ * @property array|null $genre
+ * @property Carbon|null $release_date
+ * @property Carbon|null $date_started
+ * @property Carbon|null $date_finished
+ */
+class Game extends Model
+{
+ /** @use HasFactory */
+ use HasFactory;
+
+ protected $fillable = [
+ 'user_id',
+ 'title',
+ 'platform',
+ 'genre',
+ 'description',
+ 'cover_url',
+ 'release_date',
+ 'developer',
+ 'publisher',
+ 'status',
+ 'ownership',
+ 'rating',
+ 'hours_played',
+ 'completion_percentage',
+ 'rawg_id',
+ 'igdb_id',
+ 'mobygames_id',
+ 'date_started',
+ 'date_finished',
+ 'notes',
+ ];
+
+ /**
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => PlayingStatus::class,
+ 'ownership' => OwnershipStatus::class,
+ 'platform' => 'array',
+ 'genre' => 'array',
+ 'rating' => 'integer',
+ 'hours_played' => 'decimal:1',
+ 'completion_percentage' => 'integer',
+ 'release_date' => 'date',
+ 'date_started' => 'date',
+ 'date_finished' => 'date',
+ 'rawg_id' => 'integer',
+ 'igdb_id' => 'integer',
+ 'mobygames_id' => 'integer',
+ ];
+ }
+
+ /**
+ * @return BelongsTo
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/app/Models/Movie.php b/app/Models/Movie.php
index 6876a82..3ac05a2 100644
--- a/app/Models/Movie.php
+++ b/app/Models/Movie.php
@@ -5,12 +5,37 @@
namespace App\Models;
use App\Enums\WatchingStatus;
+use Database\Factories\MovieFactory;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Support\Carbon;
+/**
+ * @property string $title
+ * @property string|null $original_title
+ * @property string|null $director
+ * @property string|null $imdb_id
+ * @property string|null $poster_url
+ * @property string|null $description
+ * @property string|null $genres
+ * @property string|null $imdb_url
+ * @property string|null $title_type
+ * @property string|null $show_name
+ * @property string|null $notes
+ * @property string|null $review
+ * @property WatchingStatus $status
+ * @property Carbon|null $release_date
+ * @property Carbon|null $date_watched
+ * @property Carbon|null $date_added
+ * @property Carbon|null $date_rated
+ * @property Carbon|null $metadata_fetched_at
+ */
class Movie extends Model
{
+ /** @use HasFactory */
use HasFactory;
protected $fillable = [
@@ -42,6 +67,9 @@ class Movie extends Model
'episode_number',
];
+ /**
+ * @return array
+ */
protected function casts(): array
{
return [
@@ -61,6 +89,21 @@ protected function casts(): array
];
}
+ protected static function booted(): void
+ {
+ static::saved(function (Movie $movie): void {
+ if ($movie->isLikelyEpisode() && $movie->show_name) {
+ static::where('user_id', $movie->user_id)
+ ->where('title', $movie->show_name)
+ ->whereIn('title_type', ['TV Series', 'TV Mini Series'])
+ ->update(['updated_at' => now()]);
+ }
+ });
+ }
+
+ /**
+ * @return BelongsTo
+ */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
@@ -68,33 +111,47 @@ public function user(): BelongsTo
/**
* Get episodes for this series.
+ *
+ * @return HasMany
*/
- public function episodes(): \Illuminate\Database\Eloquent\Relations\HasMany
+ public function episodes(): HasMany
{
return $this->hasMany(Movie::class, 'show_name', 'title')
->where('title_type', 'TV Episode')
->where('user_id', $this->user_id);
}
- public function scopeForUser($query, User $user)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeForUser(Builder $query, User $user): Builder
{
return $query->where('user_id', $user->id);
}
- public function scopeWithStatus($query, WatchingStatus $status)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeWithStatus(Builder $query, WatchingStatus $status): Builder
{
return $query->where('status', $status);
}
+ /**
+ * @return array
+ */
public function getGenreListAttribute(): array
{
- if (empty($this->genres)) {
+ $genres = $this->genres;
+ if (! is_string($genres) || $genres === '') {
return [];
}
- return collect(explode(',', $this->genres))
- ->map(fn ($genre) => trim($genre))
- ->filter(fn ($genre) => $genre !== '')
+ return collect(explode(',', $genres))
+ ->map(fn ($genre): string => trim((string) $genre))
+ ->filter(fn ($genre): bool => $genre !== '')
->values()
->all();
}
@@ -125,8 +182,8 @@ public function getSeasonEpisodeLabelAttribute(): ?string
return null;
}
- return 'S' . str_pad((string) $this->season_number, 2, '0', STR_PAD_LEFT)
- . 'E' . str_pad((string) $this->episode_number, 2, '0', STR_PAD_LEFT);
+ return 'S'.str_pad((string) $this->season_number, 2, '0', STR_PAD_LEFT)
+ .'E'.str_pad((string) $this->episode_number, 2, '0', STR_PAD_LEFT);
}
public function isEpisode(): bool
@@ -136,7 +193,11 @@ public function isEpisode(): bool
public function isLikelyEpisode(): bool
{
- return $this->isEpisode() || $this->title_type === 'TV Episode';
+ if ($this->isEpisode()) {
+ return true;
+ }
+
+ return $this->title_type === 'TV Episode';
}
/**
@@ -152,16 +213,16 @@ public static function propagateShowPoster(int $userId, ?string $showName, ?stri
$query = static::where('user_id', $userId);
if ($showName) {
- $query->where(function ($q) use ($showName, $titlePrefix) {
+ $query->where(function ($q) use ($showName, $titlePrefix): void {
$q->where('show_name', $showName);
if ($titlePrefix) {
- $q->orWhere('title', 'like', $titlePrefix . ':%')
+ $q->orWhere('title', 'like', $titlePrefix.':%')
->orWhere('title', $titlePrefix);
}
});
} elseif ($titlePrefix) {
- $query->where(function ($q) use ($titlePrefix) {
- $q->where('title', 'like', $titlePrefix . ':%')
+ $query->where(function ($q) use ($titlePrefix): void {
+ $q->where('title', 'like', $titlePrefix.':%')
->orWhere('title', $titlePrefix);
});
} else {
@@ -181,14 +242,17 @@ public static function propagateShowPoster(int $userId, ?string $showName, ?stri
return $updated;
}
+ /**
+ * @return array
+ */
public static function getAllGenresForUser(int $userId): array
{
return static::where('user_id', $userId)
->whereNotNull('genres')
->pluck('genres')
- ->flatMap(fn ($s) => explode(',', $s))
- ->map(fn ($s) => trim($s))
- ->filter(fn ($s) => $s !== '')
+ ->flatMap(fn ($s): array => is_string($s) ? explode(',', $s) : [])
+ ->map(fn ($s): string => trim((string) $s))
+ ->filter(fn ($s): bool => $s !== '')
->unique()
->sort()
->values()
diff --git a/app/Models/Shelf.php b/app/Models/Shelf.php
index 51f25de..b613d28 100644
--- a/app/Models/Shelf.php
+++ b/app/Models/Shelf.php
@@ -4,6 +4,7 @@
namespace App\Models;
+use Database\Factories\ShelfFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,6 +13,7 @@
class Shelf extends Model
{
+ /** @use HasFactory */
use HasFactory;
protected $fillable = [
@@ -22,18 +24,24 @@ class Shelf extends Model
protected static function booted(): void
{
- static::creating(function (Shelf $shelf) {
+ static::creating(function (Shelf $shelf): void {
if (empty($shelf->slug)) {
$shelf->slug = Str::slug($shelf->name);
}
});
}
+ /**
+ * @return BelongsTo
+ */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
+ /**
+ * @return BelongsToMany
+ */
public function books(): BelongsToMany
{
return $this->belongsToMany(Book::class)->withTimestamps();
diff --git a/app/Models/Show.php b/app/Models/Show.php
index 93d6903..b3a34bd 100644
--- a/app/Models/Show.php
+++ b/app/Models/Show.php
@@ -5,6 +5,8 @@
namespace App\Models;
use App\Enums\WatchingStatus;
+use Database\Factories\ShowFactory;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,6 +14,7 @@
class Show extends Model
{
+ /** @use HasFactory */
use HasFactory;
protected $fillable = [
@@ -36,6 +39,9 @@ class Show extends Model
'metadata_fetched_at',
];
+ /**
+ * @return array
+ */
protected function casts(): array
{
return [
@@ -50,47 +56,68 @@ protected function casts(): array
];
}
+ /**
+ * @return BelongsTo
+ */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
+ /**
+ * @return HasMany
+ */
public function episodes(): HasMany
{
return $this->hasMany(Episode::class);
}
- public function scopeForUser($query, User $user)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeForUser(Builder $query, User $user): Builder
{
return $query->where('user_id', $user->id);
}
- public function scopeWithStatus($query, WatchingStatus $status)
+ /**
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeWithStatus(Builder $query, WatchingStatus $status): Builder
{
return $query->where('status', $status);
}
+ /**
+ * @return array
+ */
public function getGenreListAttribute(): array
{
- if (empty($this->genres)) {
+ $genres = $this->genres;
+ if (! is_string($genres) || $genres === '') {
return [];
}
- return collect(explode(',', $this->genres))
- ->map(fn ($genre) => trim($genre))
- ->filter(fn ($genre) => $genre !== '')
+ return collect(explode(',', $genres))
+ ->map(fn ($genre): string => trim((string) $genre))
+ ->filter(fn ($genre): bool => $genre !== '')
->values()
->all();
}
+ /**
+ * @return array
+ */
public static function getAllGenresForUser(int $userId): array
{
return static::where('user_id', $userId)
->whereNotNull('genres')
->pluck('genres')
- ->flatMap(fn ($s) => explode(',', $s))
- ->map(fn ($s) => trim($s))
- ->filter(fn ($s) => $s !== '')
+ ->flatMap(fn ($s): array => is_string($s) ? explode(',', $s) : [])
+ ->map(fn ($s): string => trim((string) $s))
+ ->filter(fn ($s): bool => $s !== '')
->unique()
->sort()
->values()
diff --git a/app/Models/User.php b/app/Models/User.php
index 39cf7e2..2bb4891 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -1,8 +1,11 @@
*/
+ /** @use HasFactory */
use HasFactory, Notifiable;
/**
@@ -48,33 +51,63 @@ protected function casts(): array
];
}
+ /** @return HasMany */
public function anime(): HasMany
{
return $this->hasMany(Anime::class);
}
+ /** @return HasMany */
public function books(): HasMany
{
return $this->hasMany(Book::class);
}
+ /** @return HasMany */
public function movies(): HasMany
{
return $this->hasMany(Movie::class);
}
+ /** @return HasMany */
public function shows(): HasMany
{
return $this->hasMany(Show::class);
}
+ /** @return HasMany */
public function comics(): HasMany
{
return $this->hasMany(Comic::class);
}
+ /** @return HasMany */
public function episodes(): HasMany
{
return $this->hasMany(Episode::class);
}
+
+ /** @return HasMany */
+ public function games(): HasMany
+ {
+ return $this->hasMany(Game::class);
+ }
+
+ /** @return HasMany */
+ public function albums(): HasMany
+ {
+ return $this->hasMany(Album::class);
+ }
+
+ /** @return HasMany */
+ public function boardGames(): HasMany
+ {
+ return $this->hasMany(BoardGame::class);
+ }
+
+ /** @return HasMany */
+ public function concerts(): HasMany
+ {
+ return $this->hasMany(Concert::class);
+ }
}
diff --git a/app/Policies/AlbumPolicy.php b/app/Policies/AlbumPolicy.php
new file mode 100644
index 0000000..cd522d8
--- /dev/null
+++ b/app/Policies/AlbumPolicy.php
@@ -0,0 +1,36 @@
+id === $album->user_id;
+ }
+
+ public function create(User $user): bool
+ {
+ return true;
+ }
+
+ public function update(User $user, Album $album): bool
+ {
+ return $user->id === $album->user_id;
+ }
+
+ public function delete(User $user, Album $album): bool
+ {
+ return $user->id === $album->user_id;
+ }
+}
diff --git a/app/Policies/BoardGamePolicy.php b/app/Policies/BoardGamePolicy.php
new file mode 100644
index 0000000..fddf8a3
--- /dev/null
+++ b/app/Policies/BoardGamePolicy.php
@@ -0,0 +1,36 @@
+id === $boardGame->user_id;
+ }
+
+ public function create(User $user): bool
+ {
+ return true;
+ }
+
+ public function update(User $user, BoardGame $boardGame): bool
+ {
+ return $user->id === $boardGame->user_id;
+ }
+
+ public function delete(User $user, BoardGame $boardGame): bool
+ {
+ return $user->id === $boardGame->user_id;
+ }
+}
diff --git a/app/Policies/ConcertPolicy.php b/app/Policies/ConcertPolicy.php
new file mode 100644
index 0000000..d3c4fbc
--- /dev/null
+++ b/app/Policies/ConcertPolicy.php
@@ -0,0 +1,36 @@
+id === $concert->user_id;
+ }
+
+ public function create(User $user): bool
+ {
+ return true;
+ }
+
+ public function update(User $user, Concert $concert): bool
+ {
+ return $user->id === $concert->user_id;
+ }
+
+ public function delete(User $user, Concert $concert): bool
+ {
+ return $user->id === $concert->user_id;
+ }
+}
diff --git a/app/Policies/GamePolicy.php b/app/Policies/GamePolicy.php
new file mode 100644
index 0000000..596a3b2
--- /dev/null
+++ b/app/Policies/GamePolicy.php
@@ -0,0 +1,36 @@
+id === $game->user_id;
+ }
+
+ public function create(User $user): bool
+ {
+ return true;
+ }
+
+ public function update(User $user, Game $game): bool
+ {
+ return $user->id === $game->user_id;
+ }
+
+ public function delete(User $user, Game $game): bool
+ {
+ return $user->id === $game->user_id;
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 719a47f..991e669 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -1,7 +1,12 @@
app->isProduction());
+
+ // Trust proxies from container/private networks only (Traefik/subpath)
+ $trustedProxies = config('app.trusted_proxies', '');
+ Request::setTrustedProxies(
+ explode(',', is_string($trustedProxies) ? $trustedProxies : ''),
+ Request::HEADER_X_FORWARDED_FOR |
+ Request::HEADER_X_FORWARDED_HOST |
+ Request::HEADER_X_FORWARDED_PORT |
+ Request::HEADER_X_FORWARDED_PROTO |
+ Request::HEADER_X_FORWARDED_PREFIX
);
if ($this->app->environment('production')) {
- \Illuminate\Support\Facades\URL::forceScheme('https');
- \Illuminate\Support\Facades\URL::forceRootUrl(config('app.url'));
+ URL::forceScheme('https');
+ $appUrl = config('app.url');
+ URL::forceRootUrl(is_string($appUrl) ? $appUrl : null);
}
}
}
diff --git a/app/Providers/VoltServiceProvider.php b/app/Providers/VoltServiceProvider.php
index e61d984..049ece5 100644
--- a/app/Providers/VoltServiceProvider.php
+++ b/app/Providers/VoltServiceProvider.php
@@ -1,5 +1,7 @@
connector = new BggConnector;
+ }
+
+ /**
+ * @return list>
+ */
+ public function search(string $query): array
+ {
+ try {
+ // Try exact match first, then fall back to fuzzy
+ $exactResponse = $this->connector->send(new SearchBoardGames($query, exact: true));
+ $fuzzyResponse = $this->connector->send(new SearchBoardGames($query));
+
+ $results = [];
+ $seenIds = [];
+
+ foreach ([$exactResponse, $fuzzyResponse] as $response) {
+ if (! $response->successful()) {
+ continue;
+ }
+
+ $xml = new SimpleXMLElement($response->body());
+
+ foreach ($xml->item as $item) {
+ $id = (int) $item['id'];
+ if (isset($seenIds[$id])) {
+ continue;
+ }
+ $seenIds[$id] = true;
+
+ $results[] = [
+ 'bgg_id' => $id,
+ 'title' => (string) $item->name['value'],
+ 'year_published' => property_exists($item, 'yearpublished') && $item->yearpublished !== null ? (int) $item->yearpublished['value'] : null,
+ ];
+ }
+ }
+
+ return array_slice($results, 0, 20);
+ } catch (Exception) {
+ return [];
+ }
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getDetails(int $bggId): ?array
+ {
+ try {
+ $response = $this->connector->send(new GetBoardGameDetails($bggId));
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $xml = new SimpleXMLElement($response->body());
+ $item = $xml->item[0] ?? null;
+
+ if (! $item) {
+ return null;
+ }
+
+ $title = null;
+ foreach ($item->name as $name) {
+ if ((string) $name['type'] === 'primary') {
+ $title = (string) $name['value'];
+ break;
+ }
+ }
+
+ $coverUrl = property_exists($item, 'image') && $item->image !== null ? (string) $item->image : null;
+
+ $designers = [];
+ $publishers = [];
+ $genres = [];
+
+ foreach ($item->link as $link) {
+ $type = (string) $link['type'];
+ $value = (string) $link['value'];
+
+ if ($type === 'boardgamedesigner') {
+ $designers[] = $value;
+ } elseif ($type === 'boardgamepublisher') {
+ $publishers[] = $value;
+ } elseif ($type === 'boardgamecategory') {
+ $genres[] = $value;
+ }
+ }
+
+ $bggRating = null;
+ if (property_exists($item->statistics->ratings, 'average') && $item->statistics->ratings->average !== null) {
+ $avg = (float) $item->statistics->ratings->average['value'];
+ if ($avg > 0) {
+ $bggRating = round($avg, 2);
+ }
+ }
+
+ return [
+ 'bgg_id' => $bggId,
+ 'title' => $title ?? 'Unknown',
+ 'description' => property_exists($item, 'description') && $item->description !== null ? html_entity_decode(strip_tags((string) $item->description)) : null,
+ 'cover_url' => $coverUrl,
+ 'year_published' => property_exists($item, 'yearpublished') && $item->yearpublished !== null ? (int) $item->yearpublished['value'] : null,
+ 'designer' => $designers === [] ? null : implode(', ', array_slice($designers, 0, 3)),
+ 'publisher' => $publishers === [] ? null : $publishers[0],
+ 'min_players' => property_exists($item, 'minplayers') && $item->minplayers !== null ? (int) $item->minplayers['value'] : null,
+ 'max_players' => property_exists($item, 'maxplayers') && $item->maxplayers !== null ? (int) $item->maxplayers['value'] : null,
+ 'playing_time' => property_exists($item, 'playingtime') && $item->playingtime !== null ? (int) $item->playingtime['value'] : null,
+ 'genres' => $genres,
+ 'bgg_rating' => $bggRating,
+ ];
+ } catch (Exception) {
+ return null;
+ }
+ }
+}
diff --git a/app/Services/ComicImportService.php b/app/Services/ComicImportService.php
index 6b0d873..36e2f4f 100644
--- a/app/Services/ComicImportService.php
+++ b/app/Services/ComicImportService.php
@@ -8,15 +8,21 @@
use App\Models\Comic;
use App\Models\User;
use Carbon\Carbon;
+use Exception;
use Illuminate\Support\Collection;
+use InvalidArgumentException;
class ComicImportService
{
+ /**
+ * @return Collection>
+ */
public function parseCSV(string $content): Collection
{
$lines = explode("\n", $content);
- $headers = str_getcsv(array_shift($lines));
+ $headers = array_map(fn ($h): string => (string) $h, str_getcsv(array_shift($lines)));
+ /** @var Collection> $comics */
$comics = collect();
foreach ($lines as $line) {
@@ -30,7 +36,7 @@ public function parseCSV(string $content): Collection
continue;
}
- $data = array_combine($headers, $row);
+ $data = array_combine($headers, array_map(fn ($v): ?string => $v ?? null, $row));
$comics->push($this->mapRowToComic($data));
}
@@ -38,24 +44,33 @@ public function parseCSV(string $content): Collection
return $comics;
}
+ /**
+ * @return Collection>
+ */
public function parseJson(string $content): Collection
{
$data = json_decode($content, true);
if (! is_array($data)) {
- throw new \InvalidArgumentException('Invalid JSON: expected an array of comics.');
+ throw new InvalidArgumentException('Invalid JSON: expected an array of comics.');
}
- return collect($data)->map(fn (array $item) => $this->mapJsonToComic($item));
+ return collect($data)
+ ->map(fn ($item): array => $this->mapJsonToComic(is_array($item) ? $item : []))
+ ->values();
}
+ /**
+ * @param array